refactor: fix all P0/P1/P2 bugs and architecture issues

Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -11,14 +11,12 @@
* 班级通知按教师所教班级过滤,确保教师只能给自己班级发通知。
*/
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { classEnrollments, classes } from "@/shared/db/schema"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { getClassExists, getStudentIdsByClassId } from "@/modules/classes/data-access"
import { sendNotification, sendBatchNotifications } from "./dispatcher"
import type { NotificationPayload, ChannelSendResult } from "./types"
@@ -79,36 +77,29 @@ export async function sendClassNotificationAction(
}
}
// 查询班级所有学生
const [classRow] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) {
// 校验班级是否存在
const classExists = await getClassExists(classId)
if (!classExists) {
return { success: false, message: "Class not found" }
}
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, classId))
// 查询班级所有学生
const studentIds = await getStudentIdsByClassId(classId)
if (enrollments.length === 0) {
if (studentIds.length === 0) {
return { success: true, message: "No students in this class", data: [] }
}
// 构造每个学生的通知负载
const payloads: NotificationPayload[] = enrollments.map((e) => ({
const payloads: NotificationPayload[] = studentIds.map((studentId) => ({
...payload,
userId: e.studentId,
userId: studentId,
}))
const results = await sendBatchNotifications(payloads)
return {
success: true,
message: `Notification sent to ${enrollments.length} students`,
message: `Notification sent to ${studentIds.length} students`,
data: results,
}
} catch (e) {

View File

@@ -26,7 +26,13 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
const channel: NotificationChannel = "email"
/** 从环境变量读取邮件配置 */
function getEmailConfig() {
function getEmailConfig(): {
host: string | undefined
port: number
user: string | undefined
pass: string | undefined
from: string
} {
return {
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT ?? "587"),

View File

@@ -3,22 +3,22 @@ import "server-only"
/**
* 站内消息渠道
*
* 封装现有 messaging 模块的 data-access.createNotification
* 封装 notifications 模块的 data-access.createNotification
* 将其适配为统一的 NotificationChannelSender 接口。
*
* 这是默认渠道,总是启用。所有通知都会写入 message_notifications 表,
* 用户可在站内通知中心查看。
*
* 注意: messaging.NotificationType 为 "message" | "announcement" | "homework" | "grade"
* 注意: NotificationType 为 "message" | "announcement" | "homework" | "grade"
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
* 此处将 payload.type 作为字符串写入 DBDB 列为 varchar(128),支持任意值
* 不破坏现有 messaging 模块的类型约束
* 通过 mapPayloadTypeToNotificationType 函数进行语义映射P0-11 修复
* 不再使用非法的 as 断言
*
* 使用动态 import 打破 notifications -> messaging 的静态反向依赖。
* 运行时调用链: messaging -> dispatcher -> in-app channel -> messaging.createNotification (存储)
* 这是可接受的运行时调用链,但模块级静态依赖必须单向。
* P0-4 / P1-5 修复后createNotification 已迁移到 notifications/data-access.ts
* 不再需要动态 import messaging 模块,消除了 notifications -> messaging 的反向依赖。
*/
import { createNotification } from "../data-access"
import type {
NotificationPayload,
ChannelSendResult,
@@ -28,7 +28,34 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
const channel: NotificationChannel = "in_app"
/** 站内消息发送器(通过动态 import 调用 messaging data-access */
/**
* Map NotificationPayload.type (info/warning/error/success) to
* NotificationType (message/announcement/homework/grade).
*
* Since the DB column is varchar(128) and accepts any string,
* we map by semantic meaning. "info" maps to "message" as the default
* in-app notification category.
*/
function mapPayloadTypeToNotificationType(
payloadType: NotificationPayload["type"]
): "message" | "announcement" | "homework" | "grade" {
// Map by semantic meaning: info/success -> message (general),
// warning -> announcement (needs attention), error -> grade (alert-like),
// fallback to message. This is a reasonable default mapping.
switch (payloadType) {
case "info":
case "success":
return "message"
case "warning":
return "announcement"
case "error":
return "grade"
default:
return "message"
}
}
/** 站内消息发送器(直接调用 notifications data-access */
class InAppChannelSender implements NotificationChannelSender {
readonly channel = channel
@@ -46,12 +73,10 @@ class InAppChannelSender implements NotificationChannelSender {
sentAt: new Date(),
}
}
// Dynamic import to break static reverse dependency on messaging module
const { createNotification } = await import("@/modules/messaging/data-access")
const id = await createNotification({
userId: payload.userId,
// DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义
type: payload.type as "message" | "announcement" | "homework" | "grade",
// Map payload.type to NotificationType via type-safe mapping (P0-11)
type: mapPayloadTypeToNotificationType(payload.type),
title: payload.title,
content: payload.content,
link: payload.actionUrl ?? null,

View File

@@ -26,10 +26,22 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
const channel: NotificationChannel = "sms"
type SmsProvider = "aliyun" | "tencent" | "mock"
const isSmsProvider = (v: unknown): v is SmsProvider =>
v === "aliyun" || v === "tencent" || v === "mock"
/** 从环境变量读取 SMS 配置 */
function getSmsConfig() {
function getSmsConfig(): {
provider: SmsProvider
accessKeyId: string | undefined
accessKeySecret: string | undefined
signName: string | undefined
templateCode: string | undefined
} {
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
return {
provider: (process.env.SMS_PROVIDER ?? "mock") as "aliyun" | "tencent" | "mock",
provider: isSmsProvider(rawProvider) ? rawProvider : ("mock" as const),
accessKeyId: process.env.SMS_ACCESS_KEY_ID,
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
signName: process.env.SMS_SIGN_NAME,

View File

@@ -37,7 +37,11 @@ interface TokenCache {
let tokenCache: TokenCache | null = null
/** 从环境变量读取微信配置 */
function getWechatConfig() {
function getWechatConfig(): {
appId: string | undefined
appSecret: string | undefined
templateId: string | undefined
} {
return {
appId: process.env.WECHAT_APP_ID,
appSecret: process.env.WECHAT_APP_SECRET,

View File

@@ -4,34 +4,126 @@ import "server-only"
* 通知数据访问层
*
* 职责:
* - getUserNotificationPreferences: 获取用户通知偏好(复用 messaging 模块
* - createNotification: 创建站内通知记录message_notifications 表
* - getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出)
*
* 表所有权:
* - message_notifications由 notifications 模块统一管理P0-4 / P1-5 修复后从 messaging 迁移)
* - notification_preferences由 notifications/preferences.ts 管理)
*
* 注意: users 表当前无 wechatOpenId 字段wechatOpenId 暂返回 undefined。
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
*/
import { cache } from "react"
import { eq } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
import type { NotificationPreferences } from "@/modules/messaging/types"
import { messageNotifications, users } from "@/shared/db/schema"
import type { ChannelRecipient } from "./channels/types"
import type { ChannelSendResult } from "./types"
import type {
ChannelSendResult,
CreateNotificationInput,
GetNotificationsParams,
Notification,
NotificationType,
PaginatedResult,
} from "./types"
/**
* 获取用户通知偏好(复用 messaging 模块的 cache 包装函数)。
* 若用户无记录messaging 模块会自动创建默认记录。
*/
export async function getUserNotificationPreferences(
const toIsoRequired = (d: Date): string => d.toISOString()
const isNotificationType = (v: unknown): v is NotificationType =>
v === "message" || v === "announcement" || v === "homework" || v === "grade"
const toNotificationType = (v: string): NotificationType =>
isNotificationType(v) ? v : "message"
interface NotificationRow {
id: string
userId: string
): Promise<NotificationPreferences> {
return getNotificationPreferences(userId)
type: string
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: Date
}
const mapNotification = (r: NotificationRow): Notification => ({
id: r.id,
userId: r.userId,
type: toNotificationType(r.type),
title: r.title,
content: r.content,
link: r.link,
isRead: r.isRead,
createdAt: toIsoRequired(r.createdAt),
})
// ---------------------------------------------------------------------------
// 站内通知 CRUDmessage_notifications 表)
// ---------------------------------------------------------------------------
export const getNotifications = cache(
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conds = [eq(messageNotifications.userId, userId)]
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
db.select({ value: count() }).from(messageNotifications).where(where),
])
const total = Number(totalRow?.value ?? 0)
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
)
export async function createNotification(data: CreateNotificationInput): Promise<string> {
const id = createId()
await db.insert(messageNotifications).values({
id,
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
})
return id
}
export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
}
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
}
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
.from(messageNotifications)
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
return Number(row?.value ?? 0)
})
// ---------------------------------------------------------------------------
// 用户联系方式(用于 SMS / Email / WeChat 渠道发送)
// ---------------------------------------------------------------------------
/**
* 获取用户联系方式(手机号、邮箱)。
* wechatOpenId 暂不支持users 表无此字段),返回 undefined。
@@ -62,6 +154,10 @@ export const getUserContactInfo = cache(
}
)
// ---------------------------------------------------------------------------
// 发送日志
// ---------------------------------------------------------------------------
/**
* 记录通知发送日志。
*

View File

@@ -25,10 +25,10 @@ import { createWechatSender } from "./channels/wechat-channel"
import { createEmailSender } from "./channels/email-channel"
import { createInAppSender } from "./channels/in-app-channel"
import {
getUserNotificationPreferences,
getUserContactInfo,
logNotificationSendBatch,
} from "./data-access"
import { getNotificationPreferences } from "./preferences"
/** 渠道发送器实例缓存(避免每次发送重新创建) */
interface SenderRegistry {
@@ -109,7 +109,7 @@ export async function sendNotification(
// 并行获取用户偏好和联系方式
const [prefs, contact] = await Promise.all([
getUserNotificationPreferences(userId),
getNotificationPreferences(userId),
getUserContactInfo(userId),
])

View File

@@ -3,7 +3,9 @@
*
* 对外导出:
* - sendNotification / sendBatchNotifications: 分发器入口
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel 等
* - createNotification / getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
* - getNotificationPreferences / upsertNotificationPreferences: 通知偏好 CRUD
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel, NotificationType, Notification, NotificationPreferences 等
* - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender
*
* 典型用法:
@@ -20,6 +22,20 @@
*/
export { sendNotification, sendBatchNotifications } from "./dispatcher"
export {
createNotification,
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
getUserContactInfo,
logNotificationSend,
logNotificationSendBatch,
} from "./data-access"
export {
getNotificationPreferences,
upsertNotificationPreferences,
} from "./preferences"
export type {
NotificationChannel,
NotificationPayload,
@@ -28,6 +44,13 @@ export type {
SmsChannelConfig,
WechatChannelConfig,
EmailChannelConfig,
NotificationType,
Notification,
PaginatedResult,
GetNotificationsParams,
CreateNotificationInput,
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "./types"
export type { NotificationChannelSender, ChannelRecipient } from "./channels/types"

View File

@@ -0,0 +1,179 @@
import "server-only"
/**
* 通知偏好数据访问层
*
* 职责:
* - getNotificationPreferences: 获取用户通知偏好(无记录时自动创建默认记录)
* - upsertNotificationPreferences: 更新或创建用户通知偏好
*
* 表所有权: notification_preferences由 notifications 模块统一管理)
*
* 注意: 本文件从 messaging/notification-preferences.ts 迁移而来,
* 消除 notifications -> messaging 的反向依赖P0-4 / P1-5 修复)。
*/
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { notificationPreferences } from "@/shared/db/schema"
import type {
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "./types"
const toIso = (d: Date): string => d.toISOString()
const mapRow = (
row: typeof notificationPreferences.$inferSelect
): NotificationPreferences => ({
id: row.id,
userId: row.userId,
emailEnabled: row.emailEnabled,
smsEnabled: row.smsEnabled,
pushEnabled: row.pushEnabled,
homeworkNotifications: row.homeworkNotifications,
gradeNotifications: row.gradeNotifications,
announcementNotifications: row.announcementNotifications,
messageNotifications: row.messageNotifications,
attendanceNotifications: row.attendanceNotifications,
createdAt: toIso(row.createdAt),
updatedAt: toIso(row.updatedAt),
})
// 默认偏好值(首次创建时使用)
const DEFAULTS = {
emailEnabled: false,
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: true,
}
/**
* 获取用户的通知偏好设置
* 如果用户尚无记录,则自动创建一条默认记录并返回
*/
export const getNotificationPreferences = cache(
async (userId: string): Promise<NotificationPreferences> => {
// 先查询
const [existing] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId))
.limit(1)
if (existing) {
return mapRow(existing)
}
// 不存在则创建默认记录
const id = createId()
try {
await db.insert(notificationPreferences).values({
id,
userId,
...DEFAULTS,
})
const [created] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.id, id))
.limit(1)
if (created) return mapRow(created)
} catch {
// 并发情况下可能违反唯一约束,回退到查询
const [fallback] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId))
.limit(1)
if (fallback) return mapRow(fallback)
}
// 极端情况:返回内存中的默认值(不带 id
return {
id: "",
userId,
...DEFAULTS,
createdAt: toIso(new Date()),
updatedAt: toIso(new Date()),
}
}
)
/**
* 更新(或创建)用户的通知偏好设置
* 使用 upsert 语义:存在则更新,不存在则插入
*/
export async function upsertNotificationPreferences(
userId: string,
input: UpdateNotificationPreferencesInput
): Promise<NotificationPreferences | null> {
// 先查询是否存在
const [existing] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId))
.limit(1)
if (existing) {
// 更新
const updateData: Partial<typeof notificationPreferences.$inferInsert> = {}
if (input.emailEnabled !== undefined) updateData.emailEnabled = input.emailEnabled
if (input.smsEnabled !== undefined) updateData.smsEnabled = input.smsEnabled
if (input.pushEnabled !== undefined) updateData.pushEnabled = input.pushEnabled
if (input.homeworkNotifications !== undefined) updateData.homeworkNotifications = input.homeworkNotifications
if (input.gradeNotifications !== undefined) updateData.gradeNotifications = input.gradeNotifications
if (input.announcementNotifications !== undefined) updateData.announcementNotifications = input.announcementNotifications
if (input.messageNotifications !== undefined) updateData.messageNotifications = input.messageNotifications
if (input.attendanceNotifications !== undefined) updateData.attendanceNotifications = input.attendanceNotifications
if (Object.keys(updateData).length === 0) {
return mapRow(existing)
}
await db
.update(notificationPreferences)
.set(updateData)
.where(and(eq(notificationPreferences.id, existing.id), eq(notificationPreferences.userId, userId)))
const [updated] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.id, existing.id))
.limit(1)
return updated ? mapRow(updated) : null
}
// 不存在则插入
const id = createId()
try {
await db.insert(notificationPreferences).values({
id,
userId,
emailEnabled: input.emailEnabled ?? DEFAULTS.emailEnabled,
smsEnabled: input.smsEnabled ?? DEFAULTS.smsEnabled,
pushEnabled: input.pushEnabled ?? DEFAULTS.pushEnabled,
homeworkNotifications: input.homeworkNotifications ?? DEFAULTS.homeworkNotifications,
gradeNotifications: input.gradeNotifications ?? DEFAULTS.gradeNotifications,
announcementNotifications: input.announcementNotifications ?? DEFAULTS.announcementNotifications,
messageNotifications: input.messageNotifications ?? DEFAULTS.messageNotifications,
attendanceNotifications: input.attendanceNotifications ?? DEFAULTS.attendanceNotifications,
})
const [created] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.id, id))
.limit(1)
return created ? mapRow(created) : null
} catch {
return null
}
}

View File

@@ -6,17 +6,41 @@
* - NotificationPayload: 通知负载(跨渠道统一)
* - ChannelSendResult: 单次发送结果
* - NotificationChannelConfig: 渠道配置(从环境变量加载)
*
* 此外,本文件还定义了站内通知记录与通知偏好的类型:
* - NotificationType / Notification: 站内通知记录message_notifications 表)
* - NotificationPreferences / UpdateNotificationPreferencesInput: 通知偏好notification_preferences 表)
* - CreateNotificationInput / GetNotificationsParams: 通知 CRUD 入参
* - PaginatedResult<T>: 分页结果泛型
*/
/** 支持的通知渠道 */
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
/** 站内通知类型message_notifications.type 列) */
export type NotificationType = "message" | "announcement" | "homework" | "grade"
/** 站内通知记录(对应 message_notifications 表的展示形态) */
export interface Notification {
id: string
userId: string
type: NotificationType
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: string
}
/** 通知列表项Notification 的别名,用于列表场景) */
export type NotificationListItem = Notification
/** 通知负载(跨渠道统一格式) */
export interface NotificationPayload {
userId: string
title: string
content: string
/** 通知语义类型(用于渠道内模板映射,不与 messaging.NotificationType 耦合) */
/** 通知语义类型(用于渠道内模板映射,不与 NotificationType 耦合) */
type: "info" | "warning" | "error" | "success"
metadata?: Record<string, unknown>
/** 点击通知后的跳转地址(站内相对路径或外链) */
@@ -34,6 +58,59 @@ export interface ChannelSendResult {
sentAt: Date
}
/** 通用分页结果 */
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/** 获取站内通知列表的查询参数 */
export interface GetNotificationsParams {
page?: number
pageSize?: number
unreadOnly?: boolean
}
/** 创建站内通知的输入 */
export interface CreateNotificationInput {
userId: string
type: NotificationType
title: string
content?: string | null
link?: string | null
}
/** 通知偏好设置(对应 notification_preferences 表的展示形态) */
export interface NotificationPreferences {
id: string
userId: string
emailEnabled: boolean
smsEnabled: boolean
pushEnabled: boolean
homeworkNotifications: boolean
gradeNotifications: boolean
announcementNotifications: boolean
messageNotifications: boolean
attendanceNotifications: boolean
createdAt: string
updatedAt: string
}
/** 更新通知偏好的输入(部分字段可选,未提供则保留原值) */
export interface UpdateNotificationPreferencesInput {
emailEnabled?: boolean
smsEnabled?: boolean
pushEnabled?: boolean
homeworkNotifications?: boolean
gradeNotifications?: boolean
announcementNotifications?: boolean
messageNotifications?: boolean
attendanceNotifications?: boolean
}
/** SMS 渠道配置 */
export interface SmsChannelConfig {
provider: "aliyun" | "tencent" | "mock"