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

@@ -1,12 +1,16 @@
"use server"
import { revalidatePath } from "next/cache"
import { PermissionDeniedError, requireAuth, requirePermission } from "@/shared/lib/auth-guard"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { sendNotification } from "@/modules/notifications/dispatcher"
import { SendMessageSchema } from "./schema"
import {
SendMessageSchema,
MessageIdSchema,
UpdateNotificationPreferencesSchema,
} from "./schema"
import {
getMessages,
getMessageById,
@@ -87,9 +91,15 @@ export async function sendMessageAction(
export async function markMessageAsReadAction(messageId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markMessageAsRead(messageId, ctx.userId)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
await markMessageAsRead(parsed.data.messageId, ctx.userId)
revalidatePath("/messages")
revalidatePath(`/messages/${messageId}`)
revalidatePath(`/messages/${parsed.data.messageId}`)
return { success: true, message: "Marked as read" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -101,9 +111,15 @@ export async function markMessageAsReadAction(messageId: string): Promise<Action
export async function deleteMessageAction(messageId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_DELETE)
await deleteMessage(messageId, ctx.userId)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
await deleteMessage(parsed.data.messageId, ctx.userId)
revalidatePath("/messages")
revalidatePath(`/messages/${messageId}`)
revalidatePath(`/messages/${parsed.data.messageId}`)
return { success: true, message: "Message deleted" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -129,11 +145,18 @@ export async function getMessagesAction(
export async function getMessageDetailAction(messageId: string): Promise<ActionState<Message>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const message = await getMessageById(messageId, ctx.userId)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
const validMessageId = parsed.data.messageId
const message = await getMessageById(validMessageId, ctx.userId)
if (!message) return { success: false, message: "Message not found" }
// Auto-mark as read when viewed by receiver
if (!message.isRead && message.receiverId === ctx.userId) {
await markMessageAsRead(messageId, ctx.userId)
await markMessageAsRead(validMessageId, ctx.userId)
revalidatePath("/messages")
}
return { success: true, data: message }
@@ -160,7 +183,7 @@ export async function getNotificationsAction(
params?: { page?: number; pageSize?: number; unreadOnly?: boolean }
): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requireAuth()
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const result = await getNotifications(ctx.userId, params)
return { success: true, data: result }
} catch (e) {
@@ -174,7 +197,7 @@ export async function markNotificationAsReadAction(
notificationId: string
): Promise<ActionState<string>> {
try {
const ctx = await requireAuth()
const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markNotificationAsRead(notificationId, ctx.userId)
revalidatePath("/messages")
return { success: true, message: "Notification marked as read" }
@@ -187,7 +210,7 @@ export async function markNotificationAsReadAction(
export async function markAllNotificationsAsReadAction(): Promise<ActionState<string>> {
try {
const ctx = await requireAuth()
const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markAllNotificationsAsRead(ctx.userId)
revalidatePath("/messages")
return { success: true, message: "All notifications marked as read" }
@@ -200,7 +223,7 @@ export async function markAllNotificationsAsReadAction(): Promise<ActionState<st
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requireAuth()
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const prefs = await getNotificationPreferences(ctx.userId)
return { success: true, data: prefs }
} catch (e) {
@@ -215,12 +238,12 @@ export async function updateNotificationPreferencesAction(
formData: FormData
): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requireAuth()
const ctx = await requirePermission(Permissions.MESSAGE_READ)
// 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交)
const parseBool = (key: string): boolean => formData.get(key) === "on"
const input: UpdateNotificationPreferencesInput = {
const parsed = UpdateNotificationPreferencesSchema.safeParse({
emailEnabled: parseBool("emailEnabled"),
smsEnabled: parseBool("smsEnabled"),
pushEnabled: parseBool("pushEnabled"),
@@ -229,8 +252,14 @@ export async function updateNotificationPreferencesAction(
announcementNotifications: parseBool("announcementNotifications"),
messageNotifications: parseBool("messageNotifications"),
attendanceNotifications: parseBool("attendanceNotifications"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input: UpdateNotificationPreferencesInput = parsed.data
const updated = await upsertNotificationPreferences(ctx.userId, input)
if (!updated) {
return { success: false, message: "Failed to update notification preferences" }

View File

@@ -1,5 +1,20 @@
import "server-only"
/**
* 私信数据访问层
*
* 职责:
* - getMessages / getMessageById / getMessageThread: 私信查询
* - createMessage / markMessageAsRead / deleteMessage: 私信 CRUD
* - getUnreadMessageCount: 未读私信计数
* - getRecipients: 获取收件人列表(按 DataScope 过滤)
*
* 注意: 通知相关函数createNotification / getNotifications /
* markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount
* 已迁移到 notifications/data-access.tsP0-4 / P1-5 修复)。
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
*/
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
@@ -7,7 +22,6 @@ import { and, count, desc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import {
messages,
messageNotifications,
users,
classEnrollments,
classes,
@@ -15,18 +29,16 @@ import {
import type { DataScope } from "@/shared/types/permissions"
import type {
Message,
Notification,
NotificationType,
GetMessagesParams,
GetNotificationsParams,
CreateMessageInput,
CreateNotificationInput,
PaginatedResult,
RecipientOption,
} from "./types"
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
const toIsoRequired = (d: Date): string => d.toISOString()
interface MessageRow {
id: string
senderId: string
@@ -39,17 +51,6 @@ interface MessageRow {
createdAt: Date
}
interface NotificationRow {
id: string
userId: string
type: string
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: Date
}
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const uniqueIds = [...new Set(userIds)].filter(Boolean)
if (uniqueIds.length === 0) return new Map()
@@ -71,18 +72,7 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
isRead: r.isRead,
readAt: toIso(r.readAt),
parentMessageId: r.parentMessageId,
createdAt: toIso(r.createdAt) as string,
})
const mapNotification = (r: NotificationRow): Notification => ({
id: r.id,
userId: r.userId,
type: r.type as NotificationType,
title: r.title,
content: r.content,
link: r.link,
isRead: r.isRead,
createdAt: toIso(r.createdAt) as string,
createdAt: toIsoRequired(r.createdAt),
})
export const getMessages = cache(
@@ -94,7 +84,10 @@ export const getMessages = cache(
const conds = []
if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId))
else if (params.type === "sent") conds.push(eq(messages.senderId, params.userId))
else conds.push(or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))!)
else {
const cond = or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))
if (cond) conds.push(cond)
}
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
@@ -114,7 +107,7 @@ export const getMessageById = cache(
const [row] = await db
.select()
.from(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))))
.limit(1)
if (!row) return null
const nameMap = await resolveUserNames([row.senderId, row.receiverId])
@@ -160,7 +153,7 @@ export async function markMessageAsRead(id: string, userId: string): Promise<voi
export async function deleteMessage(id: string, userId: string): Promise<void> {
await db
.delete(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))))
}
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
@@ -171,59 +164,6 @@ export const getUnreadMessageCount = cache(async (userId: string): Promise<numbe
return Number(row?.value ?? 0)
})
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)
})
export const getRecipients = cache(
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
if (scope.type === "all") {
@@ -250,3 +190,15 @@ export const getRecipients = cache(
return []
}
)
// ---------------------------------------------------------------------------
// 向后兼容 re-export通知 CRUD 已迁移到 notifications/data-access.ts
// ---------------------------------------------------------------------------
export {
createNotification,
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
} from "@/modules/notifications/data-access"

View File

@@ -1,166 +1,11 @@
import "server-only"
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,
}
/**
* 获取用户的通知偏好设置
* 如果用户尚无记录,则自动创建一条默认记录并返回
* 通知偏好数据访问(向后兼容重导出)
*
* 注意: 通知偏好函数已迁移到 notifications/preferences.tsP0-4 / P1-5 修复)。
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
*/
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
}
}
export {
getNotificationPreferences,
upsertNotificationPreferences,
} from "@/modules/notifications/preferences"

View File

@@ -16,3 +16,26 @@ export const SendMessageSchema = z
}))
export type SendMessageInput = z.infer<typeof SendMessageSchema>
/** 校验单个 messageId / notificationId 路径参数 */
export const MessageIdSchema = z.object({
messageId: z.string().trim().min(1),
})
export type MessageIdInput = z.infer<typeof MessageIdSchema>
/** 校验通知偏好更新表单8 个布尔字段,来自 checkbox FormData */
export const UpdateNotificationPreferencesSchema = z.object({
emailEnabled: z.boolean(),
smsEnabled: z.boolean(),
pushEnabled: z.boolean(),
homeworkNotifications: z.boolean(),
gradeNotifications: z.boolean(),
announcementNotifications: z.boolean(),
messageNotifications: z.boolean(),
attendanceNotifications: z.boolean(),
})
export type UpdateNotificationPreferencesFormInput = z.infer<
typeof UpdateNotificationPreferencesSchema
>

View File

@@ -1,3 +1,12 @@
/**
* 私信模块类型定义
*
* 注意: 通知相关类型NotificationType, Notification, NotificationPreferences,
* UpdateNotificationPreferencesInput, CreateNotificationInput, GetNotificationsParams,
* PaginatedResult已迁移到 notifications/types.tsP0-4 / P1-5 修复)。
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
*/
export type MessageType = "inbox" | "sent" | "all"
export interface Message {
@@ -20,29 +29,6 @@ export interface MessageThread {
messages: Message[]
}
export type NotificationType = "message" | "announcement" | "homework" | "grade"
export interface Notification {
id: string
userId: string
type: NotificationType
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: string
}
export type NotificationListItem = Notification
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface GetMessagesParams {
userId: string
type: MessageType
@@ -50,12 +36,6 @@ export interface GetMessagesParams {
pageSize?: number
}
export interface GetNotificationsParams {
page?: number
pageSize?: number
unreadOnly?: boolean
}
export interface CreateMessageInput {
senderId: string
receiverId: string
@@ -64,14 +44,6 @@ export interface CreateMessageInput {
parentMessageId?: string | null
}
export interface CreateNotificationInput {
userId: string
type: NotificationType
title: string
content?: string | null
link?: string | null
}
export interface RecipientOption {
id: string
name: string
@@ -79,30 +51,17 @@ export interface RecipientOption {
role?: string
}
// 通知偏好设置
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
}
// ---------------------------------------------------------------------------
// 向后兼容 re-export通知相关类型已迁移到 notifications/types.ts
// ---------------------------------------------------------------------------
// 更新通知偏好的输入(部分字段可选,未提供则保留原值)
export interface UpdateNotificationPreferencesInput {
emailEnabled?: boolean
smsEnabled?: boolean
pushEnabled?: boolean
homeworkNotifications?: boolean
gradeNotifications?: boolean
announcementNotifications?: boolean
messageNotifications?: boolean
attendanceNotifications?: boolean
}
export type {
NotificationType,
Notification,
NotificationListItem,
GetNotificationsParams,
CreateNotificationInput,
PaginatedResult,
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "@/modules/notifications/types"