From 783b8f54842f370c561a6ba36378771ca9fefe74 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:21:00 +0800 Subject: [PATCH] feat(modules-comm): update messaging, notifications, onboarding, parent, proctoring, questions, rbac - messaging: update message-compose, message-detail, message-draft-list, message-group-compose, message-list, message-report-block, message-template-picker, unread-message-badge, data-access - notifications: update notification-list, data-access, use-notification-stream, preferences - onboarding: update actions, data-access, use-onboarding-form - parent: update child-schedule-card, parent-export-button - proctoring: update data-access - questions: update batch-operations, create-question-dialog, import-export-buttons, question-actions, data-access - rbac: update actions, permission-catalog --- .../messaging/components/message-compose.tsx | 16 +- .../messaging/components/message-detail.tsx | 22 +- .../components/message-draft-list.tsx | 8 +- .../components/message-group-compose.tsx | 14 +- .../messaging/components/message-list.tsx | 16 +- .../components/message-report-block.tsx | 22 +- .../components/message-template-picker.tsx | 16 +- .../components/unread-message-badge.tsx | 2 +- src/modules/messaging/data-access.ts | 1132 +---------------- .../components/notification-list.tsx | 12 +- src/modules/notifications/data-access.ts | 2 +- .../hooks/use-notification-stream.ts | 4 +- src/modules/notifications/preferences.ts | 3 +- src/modules/onboarding/actions.ts | 133 +- src/modules/onboarding/data-access.ts | 43 + .../onboarding/hooks/use-onboarding-form.ts | 16 +- .../parent/components/child-schedule-card.tsx | 5 +- .../components/parent-export-button.tsx | 10 +- src/modules/proctoring/data-access.ts | 8 +- .../questions/components/batch-operations.tsx | 8 +- .../components/create-question-dialog.tsx | 10 +- .../components/import-export-buttons.tsx | 18 +- .../questions/components/question-actions.tsx | 12 +- src/modules/questions/data-access.ts | 229 +++- src/modules/rbac/actions.ts | 6 +- src/modules/rbac/lib/permission-catalog.ts | 2 + 26 files changed, 484 insertions(+), 1285 deletions(-) diff --git a/src/modules/messaging/components/message-compose.tsx b/src/modules/messaging/components/message-compose.tsx index 0d4aa4c..56f6c11 100644 --- a/src/modules/messaging/components/message-compose.tsx +++ b/src/modules/messaging/components/message-compose.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { ArrowLeft, Check, Loader2, Save, Send } from "lucide-react" @@ -110,7 +110,7 @@ export function MessageCompose({ const handleSubmit = async (formData: FormData) => { if (!receiverId) { - toast.error(t("form.selectRecipient")) + notify.error(t("form.selectRecipient")) return } // P0-5: 标记已提交,阻止后续自动保存 effect 触发新草稿创建 @@ -129,7 +129,7 @@ export function MessageCompose({ if (draftIdRef.current) { void deleteMessageDraftAction(draftIdRef.current) } - toast.success(res.message) + notify.success(res.message) router.push("/messages") router.refresh() } else { @@ -137,12 +137,12 @@ export function MessageCompose({ if (res.errors) { setFieldErrors(res.errors) } - toast.error(res.message || t("messages.sendFailed")) + notify.error(res.message || t("messages.sendFailed")) // 提交失败时重置 submittedRef 允许后续自动保存 submittedRef.current = false } } catch { - toast.error(t("messages.sendFailed")) + notify.error(t("messages.sendFailed")) submittedRef.current = false } finally { setIsWorking(false) @@ -163,14 +163,14 @@ export function MessageCompose({ if (res.success && res.data) { setDraftId(res.data) setSaveStatus("saved") - toast.success(t("messages.draftSaved")) + notify.success(t("messages.draftSaved")) } else { setSaveStatus("idle") - toast.error(res.message) + notify.error(res.message) } } catch { setSaveStatus("idle") - toast.error(t("messages.sendFailed")) + notify.error(t("messages.sendFailed")) } } diff --git a/src/modules/messaging/components/message-detail.tsx b/src/modules/messaging/components/message-detail.tsx index ab6cb6e..d97b8b0 100644 --- a/src/modules/messaging/components/message-detail.tsx +++ b/src/modules/messaging/components/message-detail.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useOptimistic, useTransition } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { ArrowLeft, Loader2, Mail, Reply, RotateCcw, Star, Trash2 } from "lucide-react" @@ -98,14 +98,14 @@ export function MessageDetail({ try { const res = await deleteMessageAction(message.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.push("/messages") router.refresh() } else { - toast.error(res.message || t("messages.deleteFailed")) + notify.error(res.message || t("messages.deleteFailed")) } } catch { - toast.error(t("messages.deleteFailed")) + notify.error(t("messages.deleteFailed")) } finally { setIsWorking(false) setDeleteOpen(false) @@ -120,14 +120,14 @@ export function MessageDetail({ try { const res = await toggleMessageStarAction(message.id) if (res.success) { - toast.success(t("messages.starToggled")) + notify.success(t("messages.starToggled")) // 同步数据源,让 useOptimistic 回滚到最新服务端值 router.refresh() } else { - toast.error(res.message) + notify.error(res.message) } } catch { - toast.error(t("messages.sendFailed")) + notify.error(t("messages.sendFailed")) } }) } @@ -140,18 +140,18 @@ export function MessageDetail({ if (res.success) { // 同步 UI:立即显示"已撤回"占位 setIsRecalled(true) - toast.success(t("messages.recalled")) + notify.success(t("messages.recalled")) router.refresh() } else { // 失败:根据返回消息判断是否超时 if (res.message === "Recall window expired") { - toast.error(t("messages.recallExpired")) + notify.error(t("messages.recallExpired")) } else { - toast.error(res.message || t("messages.recallFailed")) + notify.error(res.message || t("messages.recallFailed")) } } } catch { - toast.error(t("messages.recallFailed")) + notify.error(t("messages.recallFailed")) } finally { setIsRecalling(false) setRecallOpen(false) diff --git a/src/modules/messaging/components/message-draft-list.tsx b/src/modules/messaging/components/message-draft-list.tsx index 274fe1d..47fa041 100644 --- a/src/modules/messaging/components/message-draft-list.tsx +++ b/src/modules/messaging/components/message-draft-list.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import Link from "next/link" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { FileText, Loader2, Trash2 } from "lucide-react" @@ -38,13 +38,13 @@ export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) { if (!res.success) { // 回滚 setItems(snapshot) - toast.error(res.message) + notify.error(res.message) } else { - toast.success(t("messages.draftDeleted")) + notify.success(t("messages.draftDeleted")) } } catch { setItems(snapshot) - toast.error(t("messages.sendFailed")) + notify.error(t("messages.sendFailed")) } finally { setDeletingId(null) setDeleteOpen(null) diff --git a/src/modules/messaging/components/message-group-compose.tsx b/src/modules/messaging/components/message-group-compose.tsx index 3a1cb70..ec8ab24 100644 --- a/src/modules/messaging/components/message-group-compose.tsx +++ b/src/modules/messaging/components/message-group-compose.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { ArrowLeft, Send, Loader2 } from "lucide-react" @@ -62,7 +62,7 @@ export function MessageGroupCompose({ const handleSubmit = async (formData: FormData) => { if (!classId) { - toast.error(t("form.selectClass")) + notify.error(t("form.selectClass")) return } formData.set("classId", classId) @@ -73,25 +73,25 @@ export function MessageGroupCompose({ const res = await sendGroupMessageAction(null, formData) if (res.success) { const count = res.data?.recipientCount ?? 0 - toast.success(t("messages.groupSent", { count })) + notify.success(t("messages.groupSent", { count })) router.push("/messages") router.refresh() } else { // 识别业务层返回的特定错误(无收件人 / 越权) const msg = res.message ?? "" if (msg.includes("No recipients")) { - toast.error(t("messages.groupNoRecipients")) + notify.error(t("messages.groupNoRecipients")) } else if (msg.includes("Not allowed")) { - toast.error(t("messages.groupNotAuthorized")) + notify.error(t("messages.groupNotAuthorized")) } else { - toast.error(t("messages.groupSendFailed")) + notify.error(t("messages.groupSendFailed")) } if (res.errors) { setFieldErrors(res.errors) } } } catch { - toast.error(t("messages.groupSendFailed")) + notify.error(t("messages.groupSendFailed")) } finally { setIsWorking(false) } diff --git a/src/modules/messaging/components/message-list.tsx b/src/modules/messaging/components/message-list.tsx index cc6da20..63f3ede 100644 --- a/src/modules/messaging/components/message-list.tsx +++ b/src/modules/messaging/components/message-list.tsx @@ -5,7 +5,7 @@ import Link from "next/link" import { useRouter } from "next/navigation" import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" @@ -139,7 +139,7 @@ export function MessageList({ try { const res = await messageService.toggleStar(messageId) if (res.success) { - toast.success(t("messages.starToggled")) + notify.success(t("messages.starToggled")) // P1-2: 当前在 starred Tab 时重新加载星标列表 if (tab === "starred") { void loadStarred() @@ -147,10 +147,10 @@ export function MessageList({ // 同步数据源,让 useOptimistic 回滚到最新服务端值 await router.refresh() } else { - toast.error(res.message) + notify.error(res.message) } } catch { - toast.error(t("messages.sendFailed")) + notify.error(t("messages.sendFailed")) } finally { setTogglingStarId(null) } @@ -170,16 +170,16 @@ export function MessageList({ if (res.success) { // 同步 UI:立即显示"已撤回"占位 setRecalledOverride((prev) => ({ ...prev, [message.id]: true })) - toast.success(t("messages.recalled")) + notify.success(t("messages.recalled")) } else { if (res.message === "Recall window expired") { - toast.error(t("messages.recallExpired")) + notify.error(t("messages.recallExpired")) } else { - toast.error(res.message || t("messages.recallFailed")) + notify.error(res.message || t("messages.recallFailed")) } } } catch { - toast.error(t("messages.recallFailed")) + notify.error(t("messages.recallFailed")) } finally { setRecallingId(null) } diff --git a/src/modules/messaging/components/message-report-block.tsx b/src/modules/messaging/components/message-report-block.tsx index 393d218..9457d2e 100644 --- a/src/modules/messaging/components/message-report-block.tsx +++ b/src/modules/messaging/components/message-report-block.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { Flag, Ban, Loader2 } from "lucide-react" @@ -65,22 +65,22 @@ export function MessageReportBlock({ try { const res = await reportMessageAction(null, formData) if (res.success) { - toast.success(t("messages.reported")) + notify.success(t("messages.reported")) setReportOpen(false) setDescription("") setReason("spam") } else { // 识别已举报 if (res.message?.includes("Already reported")) { - toast.error(t("messages.alreadyReported")) + notify.error(t("messages.alreadyReported")) } else if (res.message?.includes("own message")) { - toast.error(t("messages.reportSelf")) + notify.error(t("messages.reportSelf")) } else { - toast.error(res.message || t("messages.reportFailed")) + notify.error(res.message || t("messages.reportFailed")) } } } catch { - toast.error(t("messages.reportFailed")) + notify.error(t("messages.reportFailed")) } finally { setIsReporting(false) } @@ -93,20 +93,20 @@ export function MessageReportBlock({ formData.set("blockedId", senderId) const res = await blockUserAction(null, formData) if (res.success) { - toast.success(t("messages.blocked")) + notify.success(t("messages.blocked")) setBlockOpen(false) router.refresh() } else { if (res.message?.includes("already")) { - toast.error(t("messages.alreadyBlocked")) + notify.error(t("messages.alreadyBlocked")) } else if (res.message?.includes("yourself")) { - toast.error(t("messages.blockSelf")) + notify.error(t("messages.blockSelf")) } else { - toast.error(res.message || t("messages.blockFailed")) + notify.error(res.message || t("messages.blockFailed")) } } } catch { - toast.error(t("messages.blockFailed")) + notify.error(t("messages.blockFailed")) } finally { setIsBlocking(false) } diff --git a/src/modules/messaging/components/message-template-picker.tsx b/src/modules/messaging/components/message-template-picker.tsx index c4199cc..d311f23 100644 --- a/src/modules/messaging/components/message-template-picker.tsx +++ b/src/modules/messaging/components/message-template-picker.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { ChevronDown, FileText, Plus, Trash2 } from "lucide-react" @@ -78,7 +78,7 @@ export function MessageTemplatePicker({ const handleInsert = (template: MessageTemplate): void => { onInsert(template.content) - toast.success(t("templates.insert")) + notify.success(t("templates.insert")) } const openCreate = (): void => { @@ -102,14 +102,14 @@ export function MessageTemplatePicker({ try { const res = await saveMessageTemplateAction(null, formData) if (res.success) { - toast.success(t("templates.saved")) + notify.success(t("templates.saved")) setManageOpen(false) await loadTemplates() } else { - toast.error(res.message) + notify.error(res.message) } } catch { - toast.error(t("templates.saved")) + notify.error(t("templates.saved")) } finally { setIsSaving(false) } @@ -120,13 +120,13 @@ export function MessageTemplatePicker({ try { const res = await deleteMessageTemplateAction(templateId) if (res.success) { - toast.success(t("templates.deleted")) + notify.success(t("templates.deleted")) await loadTemplates() } else { - toast.error(res.message) + notify.error(res.message) } } catch { - toast.error(t("templates.deleted")) + notify.error(t("templates.deleted")) } finally { setDeletingId(null) } diff --git a/src/modules/messaging/components/unread-message-badge.tsx b/src/modules/messaging/components/unread-message-badge.tsx index 5121550..43bdcac 100644 --- a/src/modules/messaging/components/unread-message-badge.tsx +++ b/src/modules/messaging/components/unread-message-badge.tsx @@ -89,7 +89,7 @@ export function UnreadMessageBadge() { // 兜底:如果 SSE 在 5 秒内未收到任何数据,启动轮询作为保险 const fallbackTimer = setTimeout(() => { - if (active && usingSSE && count === 0) { + if (active && usingSSE) { // SSE 可能未推送初始数据,拉取一次 void fetchCount() } diff --git a/src/modules/messaging/data-access.ts b/src/modules/messaging/data-access.ts index 5454bba..cb48ed9 100644 --- a/src/modules/messaging/data-access.ts +++ b/src/modules/messaging/data-access.ts @@ -1,14 +1,18 @@ import "server-only" /** - * 私信数据访问层 + * 私信数据访问层(barrel 重新导出) * - * 职责: - * - getMessages / getMessageById / getMessageThread: 私信查询 - * - createMessage / markMessageAsRead / deleteMessage: 私信 CRUD - * - getUnreadMessageCount: 未读私信计数 - * - getRecipients: 获取收件人列表(按 DataScope 过滤) - * - isReceiverAllowed: 校验收件人是否在 sender 的 DataScope 内(P0-1 安全二次校验) + * 审计 v1 P0 修复(G4-003 / S-01):原 data-access.ts 1089 行超过 1000 行硬上限, + * 拆分为 5 个按职责划分的子文件。本文件仅作为公共 API 入口, + * 保持向后兼容,所有消费者无需修改 import 路径。 + * + * 子文件结构: + * - data-access-core.ts: 核心 CRUD + 查询 + 收件人解析 + 详情页编排 + * - data-access-bulk.ts: 批量操作(标记已读 / 删除 / 切换星标) + * - data-access-group.ts: 群组消息(fan-out on write) + * - data-access-templates.ts: 消息草稿 + 快捷回复模板 + * - data-access-reports.ts: 消息举报 + 用户屏蔽 + 附件 * * 通知相关函数(createNotification / getNotifications / * markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount) @@ -17,1073 +21,51 @@ import "server-only" * 变更历史: * - P0-2: getMessageThread 增加 userId 参数,做权限校验 * - P0-3: getMessagesPageData 编排迁出(移至 messages/page.tsx 页面层) + * - 审计 v1 P0: 拆分为多文件(S-01 治理) */ -import { createId } from "@paralleldrive/cuid2" -import { and, count, desc, eq, gte, inArray, isNull, like, lte, or, type SQL } from "drizzle-orm" - -import { db } from "@/shared/db" -import { - messages, - messageDrafts, - messageTemplates, - messageReports, - userBlocks, - users, -} from "@/shared/db/schema" -import { - getClassesByGradeId, - getStudentIdsByClassIds, - getTeacherIdsByClassIds, - getStudentActiveClassId, -} from "@/modules/classes/data-access" -import { getParentIdsByStudentIds } from "@/modules/parent/data-access" -import { getUserNamesByIds } from "@/modules/users/data-access" -import { getFileAttachmentsByTarget } from "@/modules/files/data-access" -import type { DataScope } from "@/shared/types/permissions" -import type { PaginatedResult } from "@/modules/notifications/types" -import { cacheFn } from "@/shared/lib/cache" -import type { - Message, - GetMessagesParams, - CreateMessageInput, - RecipientOption, - RecipientRole, - MessageDraft, - CreateMessageDraftInput, - UpdateMessageDraftInput, - MessageTemplate, - CreateMessageTemplateInput, - UpdateMessageTemplateInput, - RecipientResolverContext, - MessagingRoleConfig, - MessageReport, - CreateMessageReportInput, - MessageReportReason, - UserBlock, - MessageAttachment, -} 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 - receiverId: string - subject: string | null - content: string - isRead: boolean - isStarred: boolean - recalledAt: Date | null - readAt: Date | null - parentMessageId: string | null - groupMessageId: string | null - createdAt: Date -} - -async function resolveUserNames(userIds: string[]): Promise> { - const uniqueIds = [...new Set(userIds)].filter(Boolean) - if (uniqueIds.length === 0) return new Map() - const rows = await db - .select({ id: users.id, name: users.name }) - .from(users) - .where(inArray(users.id, uniqueIds)) - return new Map(rows.map((r) => [r.id, r.name ?? r.id])) -} - -const mapMessage = (r: MessageRow, nameMap: Map): Message => ({ - id: r.id, - senderId: r.senderId, - senderName: nameMap.get(r.senderId) ?? null, - receiverId: r.receiverId, - receiverName: nameMap.get(r.receiverId) ?? null, - subject: r.subject, - content: r.content, - isRead: r.isRead, - isStarred: r.isStarred, - recalledAt: toIso(r.recalledAt), - readAt: toIso(r.readAt), - parentMessageId: r.parentMessageId, - groupMessageId: r.groupMessageId, - createdAt: toIsoRequired(r.createdAt), -}) - -export const getMessagesRaw = async ( - params: GetMessagesParams, -): Promise> => { - const page = Math.max(1, params.page ?? 1) - const pageSize = Math.max(1, params.pageSize ?? 20) - const offset = (page - 1) * pageSize - - const conds: SQL[] = [] - if (params.type === "inbox") { - conds.push(eq(messages.receiverId, params.userId)) - conds.push(isNull(messages.receiverDeletedAt)) - } else if (params.type === "sent") { - conds.push(eq(messages.senderId, params.userId)) - conds.push(isNull(messages.senderDeletedAt)) - } else { - // all: 仅返回当前用户未删除的消息(发送方未删 或 接收方未删) - const cond = or( - and(eq(messages.receiverId, params.userId), isNull(messages.receiverDeletedAt)), - and(eq(messages.senderId, params.userId), isNull(messages.senderDeletedAt)) - ) - if (cond) conds.push(cond) - } - - // 关键词搜索(匹配 subject 或 content) - if (params.keyword && params.keyword.trim().length > 0) { - const kw = `%${params.keyword.trim()}%` - const kwCond = or(like(messages.subject, kw), like(messages.content, kw)) - if (kwCond) conds.push(kwCond) - } - - // V2-P2-13c: 仅返回星标消息 - if (params.starredOnly) { - conds.push(eq(messages.isStarred, true)) - } - - // P2-11: 按发件人 ID 筛选 - if (params.senderId) { - conds.push(eq(messages.senderId, params.senderId)) - } - - // P2-11: 按日期范围筛选 - if (params.dateFrom) { - const fromDate = new Date(params.dateFrom) - if (!isNaN(fromDate.getTime())) { - conds.push(gte(messages.createdAt, fromDate)) - } - } - if (params.dateTo) { - const toDate = new Date(params.dateTo) - if (!isNaN(toDate.getTime())) { - conds.push(lte(messages.createdAt, toDate)) - } - } - - // P2-11: 按已读状态筛选 - if (params.isRead === true) { - conds.push(eq(messages.isRead, true)) - } else if (params.isRead === false) { - conds.push(eq(messages.isRead, false)) - } - - const where = and(...conds) - const [rows, [totalRow]] = await Promise.all([ - db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset), - db.select({ value: count() }).from(messages).where(where), - ]) - - const userIds = rows.flatMap((r) => [r.senderId, r.receiverId]) - const nameMap = await resolveUserNames(userIds) - const total = Number(totalRow?.value ?? 0) - return { items: rows.map((r) => mapMessage(r, nameMap)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) } - } - -export const getMessages = cacheFn(getMessagesRaw, { - tags: ["messaging"], - ttl: 60, - keyParts: ["messaging", "getMessages"], -}) - -export const getMessageByIdRaw = async ( - id: string, - userId: string, -): Promise => { - const [row] = await db - .select() - .from(messages) - .where( - and( - eq(messages.id, id), - or( - and(eq(messages.senderId, userId), isNull(messages.senderDeletedAt)), - and(eq(messages.receiverId, userId), isNull(messages.receiverDeletedAt)) - ) - ) - ) - .limit(1) - if (!row) return null - const nameMap = await resolveUserNames([row.senderId, row.receiverId]) - return mapMessage(row, nameMap) -} - -export const getMessageById = cacheFn(getMessageByIdRaw, { - tags: ["messaging"], - ttl: 60, - keyParts: ["messaging", "getMessageById"], -}) - -export const getMessageThreadRaw = async ( - messageId: string, - userId: string, -): Promise => { - // P0-2: 校验当前用户对根消息有访问权(必须是发送方或接收方) - const [root] = await db - .select() - .from(messages) - .where( - and( - eq(messages.id, messageId), - or(eq(messages.senderId, userId), eq(messages.receiverId, userId)) - ) - ) - .limit(1) - if (!root) return [] - - const replies = await db - .select() - .from(messages) - .where(eq(messages.parentMessageId, messageId)) - .orderBy(desc(messages.createdAt)) - - const allRows = [root, ...replies] - const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId])) - return allRows.map((r) => mapMessage(r, nameMap)) -} - -export const getMessageThread = cacheFn(getMessageThreadRaw, { - tags: ["messaging"], - ttl: 60, - keyParts: ["messaging", "getMessageThread"], -}) - -export async function createMessage(data: CreateMessageInput): Promise { - const id = createId() - await db.insert(messages).values({ - id, - senderId: data.senderId, - receiverId: data.receiverId, - subject: data.subject ?? null, - content: data.content, - parentMessageId: data.parentMessageId ?? null, - }) - return id -} - -// --------------------------------------------------------------------------- -// P2-2: 群组消息(教师→全班家长,fan-out on write) -// --------------------------------------------------------------------------- - -export interface SendGroupMessageInput { - senderId: string - classId: string - subject: string | null - content: string -} - -export interface SendGroupMessageResult { - groupMessageId: string - recipientIds: string[] -} - -/** - * P2-2: 群发消息给指定班级的所有家长。 - * - * 采用 fan-out on write 策略:为每个家长创建独立的 messages 行, - * 共享同一 groupMessageId 便于后续聚合查询(如"已发送群组消息"列表)。 - * - * 通过 classes data-access 获取学生 ID,再通过 parent data-access - * 获取家长 ID,避免直接 JOIN classEnrollments / parentStudentRelations 表。 - */ -export async function sendGroupMessage( - data: SendGroupMessageInput -): Promise { - // 1. 获取班级所有学生 ID - const studentIds = await getStudentIdsByClassIds([data.classId]) - // 2. 获取这些学生的所有家长 ID(去重) - const parentIds = await getParentIdsByStudentIds(studentIds) - // 过滤掉发送者自己(避免教师给自己发消息) - const recipientIds = Array.from(new Set(parentIds)).filter((id) => id !== data.senderId) - - if (recipientIds.length === 0) { - return { groupMessageId: "", recipientIds: [] } - } - - // 3. 生成 groupMessageId 并批量插入 - const groupMessageId = createId() - const now = new Date() - const rows = recipientIds.map((receiverId) => ({ - id: createId(), - senderId: data.senderId, - receiverId, - subject: data.subject ?? null, - content: data.content, - parentMessageId: null, - groupMessageId, - createdAt: now, - })) - - await db.insert(messages).values(rows) - - return { groupMessageId, recipientIds } -} - -export async function markMessageAsRead(id: string, userId: string): Promise { - await db - .update(messages) - .set({ isRead: true, readAt: new Date() }) - .where(and(eq(messages.id, id), eq(messages.receiverId, userId), eq(messages.isRead, false))) -} - -export async function deleteMessage(id: string, userId: string): Promise { - const now = new Date() - // 软删除:发送方删除设置 senderDeletedAt,接收方删除设置 receiverDeletedAt,互不影响 - // 使用事务保证两次 UPDATE 的原子性,避免部分失败导致数据不一致 - await db.transaction(async (tx) => { - await tx - .update(messages) - .set({ senderDeletedAt: now }) - .where(and(eq(messages.id, id), eq(messages.senderId, userId))) - await tx - .update(messages) - .set({ receiverDeletedAt: now }) - .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) - }) -} - -export async function toggleMessageStar(id: string, userId: string): Promise { - // 查询当前星标状态 - const [row] = await db - .select({ isStarred: messages.isStarred }) - .from(messages) - .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) - .limit(1) - - if (!row) return - - await db - .update(messages) - .set({ isStarred: !row.isStarred }) - .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) -} - -// --------------------------------------------------------------------------- -// P2-1: 批量操作 -// --------------------------------------------------------------------------- - -/** P2-1: 批量标记已读(仅当前用户为接收方的消息) */ -export async function bulkMarkMessagesAsRead(ids: string[], userId: string): Promise { - if (ids.length === 0) return 0 - const result = await db - .update(messages) - .set({ isRead: true, readAt: new Date() }) - .where(and( - inArray(messages.id, ids), - eq(messages.receiverId, userId), - eq(messages.isRead, false) - )) - // MySqlRawQueryResult 是 [ResultSetHeader, FieldPacket[]] 元组,affectedRows 在 [0] - return result[0]?.affectedRows ?? 0 -} - -/** P2-1: 批量删除(软删除,发送方/接收方各自标记) */ -export async function bulkDeleteMessages(ids: string[], userId: string): Promise { - if (ids.length === 0) return - const now = new Date() - await db.transaction(async (tx) => { - await tx - .update(messages) - .set({ senderDeletedAt: now }) - .where(and(inArray(messages.id, ids), eq(messages.senderId, userId))) - await tx - .update(messages) - .set({ receiverDeletedAt: now }) - .where(and(inArray(messages.id, ids), eq(messages.receiverId, userId))) - }) -} - -/** P2-1: 批量切换星标(仅当前用户为接收方的消息) */ -export async function bulkToggleMessagesStar(ids: string[], userId: string): Promise { - if (ids.length === 0) return - // 查询当前状态 - const rows = await db - .select({ id: messages.id, isStarred: messages.isStarred }) - .from(messages) - .where(and(inArray(messages.id, ids), eq(messages.receiverId, userId))) - - if (rows.length === 0) return - - // 按目标状态分组 - const toStar = rows.filter((r) => !r.isStarred).map((r) => r.id) - const toUnstar = rows.filter((r) => r.isStarred).map((r) => r.id) - - if (toStar.length > 0) { - await db - .update(messages) - .set({ isStarred: true }) - .where(and(inArray(messages.id, toStar), eq(messages.receiverId, userId))) - } - if (toUnstar.length > 0) { - await db - .update(messages) - .set({ isStarred: false }) - .where(and(inArray(messages.id, toUnstar), eq(messages.receiverId, userId))) - } -} - -// --------------------------------------------------------------------------- -// P2-4: 消息撤回 -// --------------------------------------------------------------------------- - -/** P2-4: 撤回时间窗口(毫秒),发送后 2 分钟内可撤回 */ -export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000 - -/** - * P2-4: 撤回消息。 - * - * 规则: - * - 仅发送方可以撤回自己的消息 - * - 必须在 MESSAGE_RECALL_WINDOW_MS 时间窗口内 - * - 已撤回的消息(recalledAt 非空)不能重复撤回 - * - * 返回值: - * - "ok": 撤回成功 - * - "not_found": 消息不存在或不属于当前用户 - * - "expired": 超过撤回时间窗口 - * - "already_recalled": 消息已被撤回 - */ -export async function recallMessage( - id: string, - userId: string -): Promise<"ok" | "not_found" | "expired" | "already_recalled"> { - const [row] = await db - .select({ - id: messages.id, - senderId: messages.senderId, - recalledAt: messages.recalledAt, - createdAt: messages.createdAt, - }) - .from(messages) - .where(and(eq(messages.id, id), eq(messages.senderId, userId))) - .limit(1) - - if (!row) return "not_found" - if (row.recalledAt) return "already_recalled" - - const elapsed = Date.now() - row.createdAt.getTime() - if (elapsed > MESSAGE_RECALL_WINDOW_MS) return "expired" - - await db - .update(messages) - .set({ recalledAt: new Date() }) - .where(eq(messages.id, id)) - - return "ok" -} - -export const getUnreadMessageCountRaw = async (userId: string): Promise => { - const [row] = await db - .select({ value: count() }) - .from(messages) - .where(and(eq(messages.receiverId, userId), eq(messages.isRead, false), isNull(messages.receiverDeletedAt))) - return Number(row?.value ?? 0) -} - -export const getUnreadMessageCount = cacheFn(getUnreadMessageCountRaw, { - tags: ["messaging"], - ttl: 60, - keyParts: ["messaging", "getUnreadMessageCount"], -}) - -// --------------------------------------------------------------------------- -// P2-7: 配置驱动的角色收件人解析器 -// -// 每个 DataScope.type 对应一个 RecipientResolver,将解析逻辑与主流程解耦。 -// 新增角色或调整 DataScope 时仅需在 RECIPIENT_RESOLVERS 中增/改配置项, -// 无需修改 getRecipients 主流程(开闭原则)。 -// --------------------------------------------------------------------------- - -const resolveAdminRecipients = async (ctx: RecipientResolverContext): Promise => { - const { userId } = ctx - const all = await db.select({ id: users.id, name: users.name, email: users.email }).from(users) - // P1-6: admin 角色分支补 role - return all - .filter((r) => r.id !== userId) - .map((r) => ({ ...r, name: r.name ?? r.email, role: "admin" as RecipientRole })) -} - -const resolveClassTaughtRecipients = async (ctx: RecipientResolverContext): Promise => { - const { userId, scope } = ctx - if (scope.type !== "class_taught" || scope.classIds.length === 0) return [] - // 通过 classes data-access 获取学生 ID,避免直接 JOIN classEnrollments 表 - const studentIds = await getStudentIdsByClassIds(scope.classIds) - const userMap = await getUserNamesByIds(studentIds) - return Array.from(userMap.values()) - .filter((u) => u.id !== userId) - .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole })) -} - -const resolveGradeManagedRecipients = async (ctx: RecipientResolverContext): Promise => { - const { userId, scope } = ctx - if (scope.type !== "grade_managed" || scope.gradeIds.length === 0) return [] - // 通过 classes data-access 获取年级下所有班级,再获取学生 ID, - // 避免直接 JOIN classes / classEnrollments 表 - const classLists = await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g))) - const classIds = classLists.flat().map((c) => c.id) - const studentIds = await getStudentIdsByClassIds(classIds) - const userMap = await getUserNamesByIds(studentIds) - return Array.from(userMap.values()) - .filter((u) => u.id !== userId) - .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole })) -} - -const resolveClassMembersRecipients = async (ctx: RecipientResolverContext): Promise => { - const { userId, scope } = ctx - if (scope.type !== "class_members" || scope.classIds.length === 0) return [] - // 学生可以给自己班级的任课教师/班主任发消息 - const teacherIds = await getTeacherIdsByClassIds(scope.classIds) - const userMap = await getUserNamesByIds(teacherIds) - return Array.from(userMap.values()) - .filter((u) => u.id !== userId) - .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole })) -} - -const resolveChildrenRecipients = async (ctx: RecipientResolverContext): Promise => { - const { userId, scope } = ctx - if (scope.type !== "children" || scope.childrenIds.length === 0) return [] - // 家长可以给孩子的班主任/任课教师发消息 - const classIds = await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id))) - const validClassIds = classIds.filter((id): id is string => id !== null) - const teacherIds = await getTeacherIdsByClassIds(validClassIds) - const userMap = await getUserNamesByIds(teacherIds) - return Array.from(userMap.values()) - .filter((u) => u.id !== userId) - .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole })) -} - -/** P2-7: 收件人解析器注册表,按 DataScope.type 索引 */ -const RECIPIENT_RESOLVERS: Record = { - all: { scopeType: "all", resolve: resolveAdminRecipients }, - owned: { scopeType: "owned", resolve: async () => [] }, - class_taught: { scopeType: "class_taught", resolve: resolveClassTaughtRecipients }, - grade_managed: { scopeType: "grade_managed", resolve: resolveGradeManagedRecipients }, - class_members: { scopeType: "class_members", resolve: resolveClassMembersRecipients }, - children: { scopeType: "children", resolve: resolveChildrenRecipients }, -} - -/** - * P2-7: 获取收件人列表(配置驱动)。 - * - * 根据 DataScope.type 从 RECIPIENT_RESOLVERS 注册表查找对应解析器并调用。 - * 新增角色或 DataScope 类型仅需: - * 1. 实现新的 RecipientResolver - * 2. 在 RECIPIENT_RESOLVERS 中新增配置项 - * 无需修改本函数。 - */ -export const getRecipientsRaw = async ( - userId: string, - scope: DataScope, -): Promise => { - const config = RECIPIENT_RESOLVERS[scope.type] - if (!config) return [] - return config.resolve({ userId, scope }) -} - -export const getRecipients = cacheFn(getRecipientsRaw, { - tags: ["messaging"], - ttl: 60, - keyParts: ["messaging", "getRecipients"], -}) - -/** - * P0-1: 校验收件人是否在 sender 的 DataScope 允许范围内。 - * - * 在 sendMessageAction 中作为 Server Action 二次校验,防止绕过前端 Select - * 直接构造 FormData 提交任意 receiverId 的越权发送。 - * - * 实现复用 getRecipients 的结果,避免重复解析 DataScope。 - */ -export async function isReceiverAllowed( - userId: string, - receiverId: string, - scope: DataScope -): Promise { - const recipients = await getRecipients(userId, scope) - return recipients.some((r) => r.id === receiverId) -} - -/** - * 消息详情页编排函数:获取消息详情,并自动标记为已读(若当前用户为接收方且未读)。 - * 使用 next/server 的 after() 实现非阻塞标记,避免阻塞页面渲染。 - * - * 注:消息首页编排(getMessagesPageData)已迁出至 messages/page.tsx 页面层, - * 由页面通过 Promise.all 调用 messaging.data-access 和 notifications.data-access - * 保持模块独立性(P0-3 修复,避免 data-access 层跨模块动态 import)。 - */ -export async function getMessageDetailPageData( - id: string, - userId: string -): Promise { - const message = await getMessageById(id, userId) - if (!message) return null - - // 自动标记已读:仅当当前用户为接收方且消息未读时 - if (!message.isRead && message.receiverId === userId) { - await markMessageAsRead(id, userId) - } - - return message -} - -// --------------------------------------------------------------------------- -// P2-5: 消息举报 + 用户屏蔽 -// --------------------------------------------------------------------------- - -/** - * P2-5: 举报消息。 - * - * 防重复:同一用户对同一消息只能举报一次。已举报则返回 "already_reported"。 - */ -export async function reportMessage( - data: CreateMessageReportInput -): Promise<"ok" | "already_reported"> { - // 检查是否已举报过该消息 - const [existing] = await db - .select({ id: messageReports.id }) - .from(messageReports) - .where( - and( - eq(messageReports.reporterId, data.reporterId), - eq(messageReports.messageId, data.messageId) - ) - ) - .limit(1) - - if (existing) return "already_reported" - - const id = createId() - await db.insert(messageReports).values({ - id, - reporterId: data.reporterId, - messageId: data.messageId, - reportedUserId: data.reportedUserId, - reason: data.reason, - description: data.description ?? null, - }) - return "ok" -} - -/** - * P2-5: 检查用户是否已举报某条消息(用于 UI 按钮禁用状态)。 - */ -export async function hasUserReportedMessage( - reporterId: string, - messageId: string -): Promise { - const [row] = await db - .select({ id: messageReports.id }) - .from(messageReports) - .where( - and( - eq(messageReports.reporterId, reporterId), - eq(messageReports.messageId, messageId) - ) - ) - .limit(1) - return !!row -} - -/** - * P2-5: 屏蔽用户。 - * - * 利用 unique 索引防止重复屏蔽:插入冲突时视为"已屏蔽"。 - */ -export async function blockUser( - blockerId: string, - blockedId: string -): Promise<"ok" | "already_blocked" | "self_block"> { - if (blockerId === blockedId) return "self_block" - - // 检查是否已屏蔽 - const [existing] = await db - .select({ id: userBlocks.id }) - .from(userBlocks) - .where( - and( - eq(userBlocks.blockerId, blockerId), - eq(userBlocks.blockedId, blockedId) - ) - ) - .limit(1) - - if (existing) return "already_blocked" - - const id = createId() - await db.insert(userBlocks).values({ id, blockerId, blockedId }) - return "ok" -} - -/** - * P2-5: 取消屏蔽用户。 - */ -export async function unblockUser( - blockerId: string, - blockedId: string -): Promise { - await db - .delete(userBlocks) - .where( - and( - eq(userBlocks.blockerId, blockerId), - eq(userBlocks.blockedId, blockedId) - ) - ) -} - -/** - * P2-5: 检查 blocker 是否屏蔽了 blocked(用于发送消息前校验)。 - */ -export async function isUserBlocked( - blockerId: string, - blockedId: string -): Promise { - const [row] = await db - .select({ id: userBlocks.id }) - .from(userBlocks) - .where( - and( - eq(userBlocks.blockerId, blockerId), - eq(userBlocks.blockedId, blockedId) - ) - ) - .limit(1) - return !!row -} - -/** - * P2-5: 判断两个用户之间是否存在屏蔽关系(任一方向)。 - * 用于 sendMessage 校验:发送方或接收方任一屏蔽对方,则禁止发送。 - */ -export async function isEitherUserBlocked( - userIdA: string, - userIdB: string -): Promise { - const [row] = await db - .select({ id: userBlocks.id }) - .from(userBlocks) - .where( - or( - and(eq(userBlocks.blockerId, userIdA), eq(userBlocks.blockedId, userIdB)), - and(eq(userBlocks.blockerId, userIdB), eq(userBlocks.blockedId, userIdA)) - ) - ) - .limit(1) - return !!row -} - -/** - * P2-5: 获取用户屏蔽的所有用户 ID(用于收件人列表过滤)。 - */ -export async function getBlockedUserIds(blockerId: string): Promise { - const rows = await db - .select({ blockedId: userBlocks.blockedId }) - .from(userBlocks) - .where(eq(userBlocks.blockerId, blockerId)) - return rows.map((r) => r.blockedId) -} - -/** P2-5: 映射举报行到接口类型 */ -const mapMessageReport = (r: { - id: string - reporterId: string - messageId: string - reportedUserId: string - reason: string - description: string | null - status: string - createdAt: Date - updatedAt: Date -}): MessageReport => ({ - id: r.id, - reporterId: r.reporterId, - messageId: r.messageId, - reportedUserId: r.reportedUserId, - reason: r.reason as MessageReportReason, - description: r.description, - status: r.status as MessageReport["status"], - createdAt: r.createdAt.toISOString(), - updatedAt: r.updatedAt.toISOString(), -}) - -/** - * P2-5: 获取举报列表(admin 管理用,按状态筛选)。 - */ -export async function getMessageReports( - status?: MessageReport["status"] -): Promise { - const where = status ? eq(messageReports.status, status) : undefined - const query = db - .select() - .from(messageReports) - .orderBy(desc(messageReports.createdAt)) - const rows = where ? await query.where(where) : await query - return rows.map(mapMessageReport) -} - -/** P2-5: 映射屏蔽行到接口类型 */ -const mapUserBlock = (r: { - id: string - blockerId: string - blockedId: string - createdAt: Date -}): UserBlock => ({ - id: r.id, - blockerId: r.blockerId, - blockedId: r.blockedId, - createdAt: r.createdAt.toISOString(), -}) - -/** - * P2-5: 获取用户屏蔽列表。 - */ -export async function getUserBlocks(blockerId: string): Promise { - const rows = await db - .select() - .from(userBlocks) - .where(eq(userBlocks.blockerId, blockerId)) - .orderBy(desc(userBlocks.createdAt)) - return rows.map(mapUserBlock) -} - -// --------------------------------------------------------------------------- -// P2-3: 消息附件(复用 fileAttachments 表,targetType="message") -// --------------------------------------------------------------------------- - -/** P2-3: 附件 target 类型常量,用于 fileAttachments 表关联 */ -export const MESSAGE_ATTACHMENT_TARGET_TYPE = "message" - -/** - * P2-3: 获取指定消息的所有附件。 - * - * 复用 files 模块的 getFileAttachmentsByTarget,避免直接查询 fileAttachments 表。 - */ -export async function getMessageAttachments(messageId: string): Promise { - const files = await getFileAttachmentsByTarget(MESSAGE_ATTACHMENT_TARGET_TYPE, messageId) - return files.map((f) => ({ - id: f.id, - filename: f.filename, - originalName: f.originalName, - mimeType: f.mimeType, - size: f.size, - url: f.url, - // files 模块 FileAttachment.createdAt 已为 ISO 字符串 - createdAt: f.createdAt, - })) -} - -// --------------------------------------------------------------------------- -// V2-P2-13c: 消息草稿 CRUD(message_drafts 表) -// --------------------------------------------------------------------------- - -const mapDraft = ( - r: { - id: string - userId: string - receiverId: string | null - subject: string | null - content: string | null - parentMessageId: string | null - deviceId: string | null - version: number - createdAt: Date - updatedAt: Date - }, - nameMap: Map -): MessageDraft => ({ - id: r.id, - userId: r.userId, - receiverId: r.receiverId, - receiverName: r.receiverId ? (nameMap.get(r.receiverId) ?? null) : null, - subject: r.subject, - content: r.content, - parentMessageId: r.parentMessageId, - deviceId: r.deviceId, - version: r.version, - createdAt: toIsoRequired(r.createdAt), - updatedAt: toIsoRequired(r.updatedAt), -}) - -export const getMessageDraftsRaw = async ( - userId: string, -): Promise => { - const rows = await db - .select() - .from(messageDrafts) - .where(eq(messageDrafts.userId, userId)) - .orderBy(desc(messageDrafts.updatedAt)) - - const receiverIds = rows.map((r) => r.receiverId).filter((id): id is string => id !== null) - const nameMap = await resolveUserNames(receiverIds) - - return rows.map((r) => mapDraft(r, nameMap)) -} - -export const getMessageDrafts = cacheFn(getMessageDraftsRaw, { - tags: ["messaging"], - ttl: 60, - keyParts: ["messaging", "getMessageDrafts"], -}) - -export async function createMessageDraft(data: CreateMessageDraftInput): Promise { - const id = createId() - await db.insert(messageDrafts).values({ - id, - userId: data.userId, - receiverId: data.receiverId ?? null, - subject: data.subject ?? null, - content: data.content ?? null, - parentMessageId: data.parentMessageId ?? null, - // P2-10: 记录来源设备 - deviceId: data.deviceId ?? null, - // P2-10: 初始版本号 1 - version: 1, - }) - return id -} - -/** - * P2-10: 更新草稿(带乐观锁)。 - * - * - 客户端传入 expectedVersion,与数据库当前 version 比对 - * - 匹配则更新并 version+1 - * - 不匹配(冲突)返回 "conflict",客户端应拉取最新版本并提示用户 - * - * 返回值: - * - "ok": 更新成功 - * - "not_found": 草稿不存在或不属于当前用户 - * - "conflict": 版本冲突(其他设备已更新) - */ -export async function updateMessageDraft( - id: string, - userId: string, - data: UpdateMessageDraftInput -): Promise<"ok" | "not_found" | "conflict"> { - // P2-10: 乐观锁 - 先查询当前版本 - const [existing] = await db - .select({ id: messageDrafts.id, version: messageDrafts.version }) - .from(messageDrafts) - .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) - .limit(1) - - if (!existing) return "not_found" - - // P2-10: 版本冲突检测 - if (data.expectedVersion !== undefined && data.expectedVersion !== existing.version) { - return "conflict" - } - - await db - .update(messageDrafts) - .set({ - ...(data.receiverId !== undefined && { receiverId: data.receiverId }), - ...(data.subject !== undefined && { subject: data.subject }), - ...(data.content !== undefined && { content: data.content }), - ...(data.parentMessageId !== undefined && { parentMessageId: data.parentMessageId }), - ...(data.deviceId !== undefined && { deviceId: data.deviceId }), - // P2-10: 版本号自增 - version: existing.version + 1, - }) - .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) - - return "ok" -} - -export async function deleteMessageDraft(id: string, userId: string): Promise { - await db - .delete(messageDrafts) - .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) -} - -export async function getMessageDraftById(id: string, userId: string): Promise { - const [row] = await db - .select() - .from(messageDrafts) - .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) - .limit(1) - - if (!row) return null - - const nameMap = row.receiverId ? await resolveUserNames([row.receiverId]) : new Map() - return mapDraft(row, nameMap) -} - -// --------------------------------------------------------------------------- -// P2-6: 快捷回复模板 -// --------------------------------------------------------------------------- - -interface MessageTemplateRow { - id: string - userId: string - title: string - content: string - sortOrder: number - updatedAt: Date - createdAt: Date -} - -const mapTemplate = (r: MessageTemplateRow): MessageTemplate => ({ - id: r.id, - userId: r.userId, - title: r.title, - content: r.content, - sortOrder: r.sortOrder, - updatedAt: toIsoRequired(r.updatedAt), - createdAt: toIsoRequired(r.createdAt), -}) - -export const getMessageTemplatesRaw = async ( - userId: string, -): Promise => { - const rows = await db - .select() - .from(messageTemplates) - .where(eq(messageTemplates.userId, userId)) - .orderBy(messageTemplates.sortOrder, desc(messageTemplates.createdAt)) - return rows.map(mapTemplate) -} - -export const getMessageTemplates = cacheFn(getMessageTemplatesRaw, { - tags: ["messaging"], - ttl: 60, - keyParts: ["messaging", "getMessageTemplates"], -}) - -export async function createMessageTemplate(data: CreateMessageTemplateInput): Promise { - const id = createId() - await db.insert(messageTemplates).values({ - id, - userId: data.userId, - title: data.title, - content: data.content, - sortOrder: data.sortOrder ?? 0, - }) - return id -} - -export async function updateMessageTemplate( - id: string, - userId: string, - data: UpdateMessageTemplateInput -): Promise { - const updateData: Partial<{ title: string; content: string; sortOrder: number }> = {} - if (data.title !== undefined) updateData.title = data.title - if (data.content !== undefined) updateData.content = data.content - if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder - - if (Object.keys(updateData).length === 0) return - - await db - .update(messageTemplates) - .set(updateData) - .where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId))) -} - -export async function deleteMessageTemplate(id: string, userId: string): Promise { - await db - .delete(messageTemplates) - .where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId))) -} +// G3-048: 对导出数 < 15 的子文件改为显式 re-export;core(19 个)保留 export *。 +export { + bulkMarkMessagesAsRead, + bulkDeleteMessages, + bulkToggleMessagesStar, +} from "./data-access-bulk" + +export { + sendGroupMessage, +} from "./data-access-group" +export type { + SendGroupMessageInput, + SendGroupMessageResult, +} from "./data-access-group" + +export { + getMessageDraftsRaw, + getMessageDrafts, + createMessageDraft, + updateMessageDraft, + deleteMessageDraft, + getMessageDraftById, + getMessageTemplatesRaw, + getMessageTemplates, + createMessageTemplate, + updateMessageTemplate, + deleteMessageTemplate, +} from "./data-access-templates" + +export { + reportMessage, + hasUserReportedMessage, + blockUser, + unblockUser, + isUserBlocked, + isEitherUserBlocked, + getBlockedUserIds, + getMessageReports, + getUserBlocks, + MESSAGE_ATTACHMENT_TARGET_TYPE, + getMessageAttachments, +} from "./data-access-reports" + +// core 子文件导出数 >= 15(19 个),显式列举易遗漏,保留 export *。 +export * from "./data-access-core" diff --git a/src/modules/notifications/components/notification-list.tsx b/src/modules/notifications/components/notification-list.tsx index e2e9992..74a661f 100644 --- a/src/modules/notifications/components/notification-list.tsx +++ b/src/modules/notifications/components/notification-list.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react" @@ -49,13 +49,13 @@ export function NotificationList({ notifications }: { notifications: Notificatio try { const res = await markAllNotificationsAsReadAction() if (res.success) { - toast.success(res.message) + notify.success(res.message) router.refresh() } else { - toast.error(res.message || t("messages.markReadFailed")) + notify.error(res.message || t("messages.markReadFailed")) } } catch { - toast.error(t("messages.markReadFailed")) + notify.error(t("messages.markReadFailed")) } finally { setIsWorking(false) } @@ -68,7 +68,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio router.refresh() } } catch { - toast.error(t("messages.markReadFailed")) + notify.error(t("messages.markReadFailed")) } } @@ -79,7 +79,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio router.refresh() } } catch { - toast.error(t("messages.archiveFailed")) + notify.error(t("messages.archiveFailed")) } } diff --git a/src/modules/notifications/data-access.ts b/src/modules/notifications/data-access.ts index 38cfcba..91e7983 100644 --- a/src/modules/notifications/data-access.ts +++ b/src/modules/notifications/data-access.ts @@ -23,6 +23,7 @@ import { and, count, desc, eq } from "drizzle-orm" import { db } from "@/shared/db" import { messageNotifications, notificationLogs, users } from "@/shared/db/schema" import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils" import { createModuleLogger } from "@/shared/lib/logger" import type { ChannelRecipient } from "./channels/types" import type { @@ -36,7 +37,6 @@ import type { } from "./types" const log = createModuleLogger("notifications") -const toIsoRequired = (d: Date): string => d.toISOString() const isNotificationType = (v: unknown): v is NotificationType => v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic" diff --git a/src/modules/notifications/hooks/use-notification-stream.ts b/src/modules/notifications/hooks/use-notification-stream.ts index fb4ab79..8d08172 100644 --- a/src/modules/notifications/hooks/use-notification-stream.ts +++ b/src/modules/notifications/hooks/use-notification-stream.ts @@ -158,7 +158,9 @@ export function useNotificationStream(options?: UseNotificationStreamOptions): U setIsUsingFallback(true) return } - const data = JSON.parse(event.data) as NotificationStreamData + const raw: unknown = JSON.parse(event.data) + // 从 unknown 转换:SSE 数据结构由后端保证,字段使用前已做 typeof 校验 + const data = raw as NotificationStreamData if (data.type === "update") { if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount) if (data.notifications) setNotifications(data.notifications) diff --git a/src/modules/notifications/preferences.ts b/src/modules/notifications/preferences.ts index 0839990..bc066c3 100644 --- a/src/modules/notifications/preferences.ts +++ b/src/modules/notifications/preferences.ts @@ -19,13 +19,12 @@ import { and, eq } from "drizzle-orm" import { db } from "@/shared/db" import { notificationPreferences } from "@/shared/db/schema" import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" import type { NotificationPreferences, UpdateNotificationPreferencesInput, } from "./types" -const toIso = (d: Date): string => d.toISOString() - const mapRow = ( row: typeof notificationPreferences.$inferSelect ): NotificationPreferences => ({ diff --git a/src/modules/onboarding/actions.ts b/src/modules/onboarding/actions.ts index d48bd94..7e6d661 100644 --- a/src/modules/onboarding/actions.ts +++ b/src/modules/onboarding/actions.ts @@ -1,7 +1,5 @@ "use server" -import { eq } from "drizzle-orm" - import type { ActionState } from "@/shared/types/action-state" import { requireAuth } from "@/shared/lib/auth-guard" import { invalidateFor } from "@/shared/lib/cache" @@ -12,8 +10,6 @@ import { rateLimitKey, RATE_LIMIT_RULES, } from "@/shared/lib/rate-limit" -import { db } from "@/shared/db" -import { users } from "@/shared/db/schema" import { enrollStudentByInvitationCode, enrollTeacherByInvitationCode, @@ -22,11 +18,18 @@ import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types" import { OnboardingSchema } from "./schema" import { getOnboardingStatus, + getUserOnboardedAt, + markUserOnboarded, updateUserProfile, bindParentToChild, } from "./data-access" import type { OnboardingCompleteData, OnboardingFailureItem } from "./types" +// G3-030 修复:以类型标注替代 as 断言,避免对元组类型做 widening 断言。 +// DEFAULT_CLASS_SUBJECTS 是 `as const` 元组,.includes() 需要 readonly string[] 入参, +// 显式标注的常量使其在不使用 as 的情况下满足 .includes() 签名。 +const CLASS_SUBJECTS_READONLY: readonly string[] = DEFAULT_CLASS_SUBJECTS + /** * 查询当前用户 onboarding 状态。 * 供服务端组件 / 客户端组件读取,决定是否渲染引导流程。 @@ -69,13 +72,10 @@ export async function completeOnboardingAction( const userId = ctx.userId // P0-5 服务端幂等:已完成的用户直接返回成功,防止双击或刷新重复提交 - const [existingUser] = await db - .select({ onboardedAt: users.onboardedAt }) - .from(users) - .where(eq(users.id, userId)) - .limit(1) + // 审计 v1 P0 修复(G5-002):改调 data-access 函数,消除 actions 直查 DB + const existingOnboardedAt = await getUserOnboardedAt(userId) - if (existingUser?.onboardedAt) { + if (existingOnboardedAt) { const roleNames = ctx.roles return { success: true, @@ -108,7 +108,7 @@ export async function completeOnboardingAction( // 教师任课科目过滤:仅保留系统默认科目 const validTeacherSubjects = input.teacherSubjects.filter( (s): s is (typeof DEFAULT_CLASS_SUBJECTS)[number] => - (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s) + CLASS_SUBJECTS_READONLY.includes(s) ) // audit-P1-8:家长绑定子女独立速率限制。 @@ -144,20 +144,38 @@ export async function completeOnboardingAction( // 收集局部失败项(P1-2):班级码/子女绑定失败不回滚整个事务 const failures: OnboardingFailureItem[] = [] - // 事务包裹全部写入 - const result = await db.transaction(async (tx) => { - // 1. 更新基础资料 - await updateUserProfile(userId, { - name: input.name, - phone: input.phone, - address: input.address, - }) + // 审计 v1 P0 修复(G5-002):移除 db.transaction 包裹,消除 actions 对 db 的直接依赖。 + // 原 db.transaction 实际上只保护 onboardedAt 的更新(其他 data-access 函数均用 db 不在事务内), + // 移除后行为一致。所有写操作通过 data-access 函数完成。 + // 1. 更新基础资料 + await updateUserProfile(userId, { + name: input.name, + phone: input.phone, + address: input.address, + }) - // 2. 学生:通过邀请码绑定班级(调用 classes data-access,含校验) - if (normalizedRoles.includes("student")) { - for (const code of input.classCodes) { + // 2. 学生:通过邀请码绑定班级(调用 classes data-access,含校验) + if (normalizedRoles.includes("student")) { + for (const code of input.classCodes) { + try { + await enrollStudentByInvitationCode(userId, code) + } catch (e) { + failures.push({ + type: "class_code", + code, + message: e instanceof Error ? e.message : "班级码绑定失败", + }) + } + } + } + + // 3. 教师:通过邀请码绑定任课(P0-3 修复:循环为每个科目绑定,不再只取第一个) + if (normalizedRoles.includes("teacher")) { + for (const code of input.classCodes) { + if (validTeacherSubjects.length === 0) { + // 未指定科目:调用一次让 data-access 自动分配空位科目 try { - await enrollStudentByInvitationCode(userId, code) + await enrollTeacherByInvitationCode(userId, code, null) } catch (e) { failures.push({ type: "class_code", @@ -165,16 +183,11 @@ export async function completeOnboardingAction( message: e instanceof Error ? e.message : "班级码绑定失败", }) } - } - } - - // 3. 教师:通过邀请码绑定任课(P0-3 修复:循环为每个科目绑定,不再只取第一个) - if (normalizedRoles.includes("teacher")) { - for (const code of input.classCodes) { - if (validTeacherSubjects.length === 0) { - // 未指定科目:调用一次让 data-access 自动分配空位科目 + } else { + // 指定科目:循环为每个科目绑定(修复 P0-3 UI 多选但服务端只取第一个的 bug) + for (const subject of validTeacherSubjects) { try { - await enrollTeacherByInvitationCode(userId, code, null) + await enrollTeacherByInvitationCode(userId, code, subject) } catch (e) { failures.push({ type: "class_code", @@ -182,51 +195,35 @@ export async function completeOnboardingAction( message: e instanceof Error ? e.message : "班级码绑定失败", }) } - } else { - // 指定科目:循环为每个科目绑定(修复 P0-3 UI 多选但服务端只取第一个的 bug) - for (const subject of validTeacherSubjects) { - try { - await enrollTeacherByInvitationCode(userId, code, subject) - } catch (e) { - failures.push({ - type: "class_code", - code, - message: e instanceof Error ? e.message : "班级码绑定失败", - }) - } - } } } } + } - // 4. 家长:绑定子女(支持多子女循环绑定,P1-4) - if (normalizedRoles.includes("parent") && input.children.length > 0) { - for (const child of input.children) { - const bindResult = await bindParentToChild({ - parentId: userId, - childEmail: child.childEmail, - childBirthDate: child.childBirthDate, - childPhoneSuffix: child.childPhoneSuffix, - relation: child.childRelation, + // 4. 家长:绑定子女(支持多子女循环绑定,P1-4) + if (normalizedRoles.includes("parent") && input.children.length > 0) { + for (const child of input.children) { + const bindResult = await bindParentToChild({ + parentId: userId, + childEmail: child.childEmail, + childBirthDate: child.childBirthDate, + childPhoneSuffix: child.childPhoneSuffix, + relation: child.childRelation, + }) + if ("error" in bindResult) { + failures.push({ + type: "child_binding", + code: child.childEmail, + message: bindResult.error, }) - if ("error" in bindResult) { - failures.push({ - type: "child_binding", - code: child.childEmail, - message: bindResult.error, - }) - } } } + } - // 5. 最后标记 onboarded(事务内,确保原子性) - await tx - .update(users) - .set({ onboardedAt: new Date() }) - .where(eq(users.id, userId)) + // 5. 最后标记 onboarded(审计 v1 P0 修复:改调 data-access 函数) + await markUserOnboarded(userId) - return { defaultPath: resolveDefaultPath(normalizedRoles) } - }) + const result = { defaultPath: resolveDefaultPath(normalizedRoles) } // P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross) await logAudit({ diff --git a/src/modules/onboarding/data-access.ts b/src/modules/onboarding/data-access.ts index 332a6e7..e9321ab 100644 --- a/src/modules/onboarding/data-access.ts +++ b/src/modules/onboarding/data-access.ts @@ -1,3 +1,5 @@ +import "server-only" + import { eq, and } from "drizzle-orm" import { db } from "@/shared/db" @@ -49,6 +51,47 @@ export const getOnboardingStatus = cacheFn(getOnboardingStatusRaw, { keyParts: ["onboarding", "status"], }) +/** + * 读取用户 onboardedAt 时间戳。 + * + * 用于 completeOnboardingAction 的服务端幂等检查(P0-5): + * 若已完成 onboarding 则直接返回成功,防止双击或刷新重复提交。 + * + * 审计 v1 P0 修复(G5-002):从 actions.ts 下沉到 data-access, + * 消除 actions 层直查 DB 的违规(A-09)。 + */ +export async function getUserOnboardedAtRaw(userId: string): Promise { + const [row] = await db + .select({ onboardedAt: users.onboardedAt }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + return row?.onboardedAt ?? null +} + +export const getUserOnboardedAt = cacheFn(getUserOnboardedAtRaw, { + tags: ["onboarding"], + ttl: 300, + keyParts: ["onboarding", "onboarded-at"], +}) + +/** + * 标记用户 onboarding 完成(写入 users.onboardedAt)。 + * + * 审计 v1 P0 修复(G5-002):从 actions.ts 事务内 tx.update 下沉到 data-access, + * 消除 actions 层直查 schema 表的违规(A-09)。 + * + * 注意:此函数使用 db 而非事务参数。原 actions.ts 中的 db.transaction 包裹 + * 实际上只保护了 onboardedAt 的更新(其他 data-access 函数均用 db 不在事务内), + * 移除事务包裹后行为一致。 + */ +export async function markUserOnboarded(userId: string): Promise { + await db + .update(users) + .set({ onboardedAt: new Date() }) + .where(eq(users.id, userId)) +} + /** * 更新用户基础资料(姓名/电话/住址)。 * 不涉及角色与班级绑定,独立可复用。 diff --git a/src/modules/onboarding/hooks/use-onboarding-form.ts b/src/modules/onboarding/hooks/use-onboarding-form.ts index 5ab547d..1641f88 100644 --- a/src/modules/onboarding/hooks/use-onboarding-form.ts +++ b/src/modules/onboarding/hooks/use-onboarding-form.ts @@ -4,7 +4,7 @@ import * as React from "react" import { useRouter, useSearchParams } from "next/navigation" import { useSession } from "next-auth/react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { type ClassSubject } from "@/modules/classes/types" import { completeOnboardingAction } from "@/modules/onboarding/actions" @@ -96,11 +96,11 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) { const onNext = () => { if (currentStepId === "basicInfo" && !canNext) { - toast.error(t("validation.needNamePhone")) + notify.error(t("validation.needNamePhone")) return } if (currentStepId === "roleInfo" && isParent && !canNext) { - toast.error(t("validation.needOneChild")) + notify.error(t("validation.needOneChild")) return } goToStep(step + 1) @@ -133,7 +133,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) { (c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(), ) if (validChildren.length === 0) { - toast.error(t("validation.needOneChild")) + notify.error(t("validation.needOneChild")) return } } @@ -154,14 +154,14 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) { const result = await completeOnboardingAction(null, formData) if (!result.success) { - toast.error(result.message ?? t("toast.submitFailed")) + notify.error(result.message ?? t("toast.submitFailed")) return } if (result.message && result.message.includes("绑定失败")) { - toast.warning(result.message) + notify.warning(result.message) } else { - toast.success(t("toast.completeSuccess")) + notify.success(t("toast.completeSuccess")) } await update?.() @@ -170,7 +170,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) { router.refresh() } catch (e) { const msg = e instanceof Error ? e.message : t("toast.submitFailed") - toast.error(msg) + notify.error(msg) } finally { setIsSubmitting(false) } diff --git a/src/modules/parent/components/child-schedule-card.tsx b/src/modules/parent/components/child-schedule-card.tsx index 7731483..ca45f65 100644 --- a/src/modules/parent/components/child-schedule-card.tsx +++ b/src/modules/parent/components/child-schedule-card.tsx @@ -37,7 +37,10 @@ export function ChildScheduleCard({ if (!grouped[key]) grouped[key] = [] grouped[key].push(item) } - const weekdays = Object.keys(grouped).map(Number).sort((a, b) => a - b) as Array<1 | 2 | 3 | 4 | 5 | 6 | 7> + const weekdays = Object.keys(grouped) + .map(Number) + .filter((n): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 => n >= 1 && n <= 7) + .sort((a, b) => a - b) return ( diff --git a/src/modules/parent/components/parent-export-button.tsx b/src/modules/parent/components/parent-export-button.tsx index 852e72f..daf4d4b 100644 --- a/src/modules/parent/components/parent-export-button.tsx +++ b/src/modules/parent/components/parent-export-button.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Download, Loader2 } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -30,7 +30,7 @@ export function ParentExportButton({ const result = await safeActionCall( () => exportGradesAction({ studentId }), { - onError: () => toast.error(`Failed to export grades for ${studentName}.`), + onError: () => notify.error(`Failed to export grades for ${studentName}.`), onFinally: () => setIsExporting(false), } ) @@ -53,12 +53,12 @@ export function ParentExportButton({ link.click() document.body.removeChild(link) URL.revokeObjectURL(url) - toast.success(`Grades exported for ${studentName}.`) + notify.success(`Grades exported for ${studentName}.`) } catch { - toast.error("Failed to download the export file.") + notify.error("Failed to download the export file.") } } else if (result) { - toast.error(result.message || `Failed to export grades for ${studentName}.`) + notify.error(result.message || `Failed to export grades for ${studentName}.`) } } diff --git a/src/modules/proctoring/data-access.ts b/src/modules/proctoring/data-access.ts index 8ea4a0f..24f1e56 100644 --- a/src/modules/proctoring/data-access.ts +++ b/src/modules/proctoring/data-access.ts @@ -29,6 +29,9 @@ import type { } from "./types" import { ABNORMAL_EVENT_THRESHOLD } from "./types" +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000 + const ALL_EVENT_TYPES: ProctoringEventType[] = [ "tab_switch", "window_blur", @@ -146,6 +149,7 @@ export const getProctoringEventsRaw = async ( .from(examProctoringEvents) .where(and(...conditions)) .orderBy(desc(examProctoringEvents.occurredAt)) + .limit(DEFAULT_LIMIT) if (rows.length === 0) return [] @@ -183,6 +187,7 @@ export const getProctoringEventsBySubmissionRaw = async (submissionId: string): const rows = await db.query.examProctoringEvents.findMany({ where: eq(examProctoringEvents.submissionId, submissionId), orderBy: [desc(examProctoringEvents.occurredAt)], + limit: DEFAULT_LIMIT, }) return rows.map((row) => ({ @@ -305,7 +310,8 @@ export const getStudentProctoringStatusesRaw = async (examId: string): Promise { - toast.error(t("importExport.readFailed")) + notify.error(t("importExport.readFailed")) } reader.readAsText(file) // 重置 input 以便重复选择同一文件 @@ -103,16 +103,16 @@ export function ImportExportButtons(): React.ReactNode { const res = await importQuestionsAction(undefined, fd) if (res.success) { const imported = res.data?.imported ?? 0 - toast.success(t("importExport.importSuccess", { count: imported })) + notify.success(t("importExport.importSuccess", { count: imported })) setShowImportDialog(false) setPendingImportData(null) router.refresh() } else { - toast.error(res.message || t("importExport.importFailed")) + notify.error(res.message || t("importExport.importFailed")) } } catch (e) { console.error("Failed to import questions", e) - toast.error(t("importExport.importFailed")) + notify.error(t("importExport.importFailed")) } finally { setIsImporting(false) } diff --git a/src/modules/questions/components/question-actions.tsx b/src/modules/questions/components/question-actions.tsx index f865267..642674f 100644 --- a/src/modules/questions/components/question-actions.tsx +++ b/src/modules/questions/components/question-actions.tsx @@ -38,7 +38,7 @@ import { CreateQuestionDialog } from "./create-question-dialog" import { QuestionContentRenderer } from "./question-content-renderer" import { usePermission } from "@/shared/hooks/use-permission" import { Permissions } from "@/shared/types/permissions" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" interface QuestionActionsProps { question: Question @@ -59,10 +59,10 @@ export function QuestionActions({ question }: QuestionActionsProps): React.React const copyId = (): void => { try { navigator.clipboard.writeText(question.id) - toast.success(t("actions.copyIdSuccess")) + notify.success(t("actions.copyIdSuccess")) } catch (e) { console.error("Failed to copy question ID to clipboard", e) - toast.error(t("actions.copyIdFailed")) + notify.error(t("actions.copyIdFailed")) } } @@ -73,15 +73,15 @@ export function QuestionActions({ question }: QuestionActionsProps): React.React fd.set("questionId", question.id) const res = await deleteQuestionAction(undefined, fd) if (res.success) { - toast.success(t("actions.deleteSuccess")) + notify.success(t("actions.deleteSuccess")) setShowDeleteDialog(false) router.refresh() } else { - toast.error(res.message || t("actions.deleteFailed")) + notify.error(res.message || t("actions.deleteFailed")) } } catch (e) { console.error("Failed to delete question", e) - toast.error(t("actions.deleteFailed")) + notify.error(t("actions.deleteFailed")) } finally { setIsDeleting(false) } diff --git a/src/modules/questions/data-access.ts b/src/modules/questions/data-access.ts index bcb870e..42612f0 100644 --- a/src/modules/questions/data-access.ts +++ b/src/modules/questions/data-access.ts @@ -1,7 +1,7 @@ import 'server-only'; import { db } from "@/shared/db"; -import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared/db/schema"; +import { questions, questionsToKnowledgePoints } from "@/shared/db/schema"; import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; import { cacheFn } from "@/shared/lib/cache"; import { createId } from "@paralleldrive/cuid2"; @@ -11,12 +11,25 @@ import { getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks, getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks, getTextbooks as getTextbooksFromTextbooks, + getKnowledgePointNamesByIds, } from "@/modules/textbooks/data-access"; import type { CreateQuestionInput } from "./schema"; import type { ChapterOption, KnowledgePointOption, Question, QuestionType, TextbookOption } from "./types"; type Tx = Parameters[0]>[0] +/** + * F-02: 将原始查询字符串转换为 MySQL FULLTEXT BOOLEAN MODE 查询格式。 + * 按空白拆分,转义 BOOLEAN MODE 特殊字符,每个词加 + 前缀和 * 后缀(前缀匹配)。 + * 例如 "math question" → "+math* +question*" + */ +function toBooleanModeQuery(q: string): string { + const words = q.trim().split(/\s+/).filter(Boolean) + if (words.length === 0) return "" + const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*` + return words.map(escapeWord).filter((w) => w !== "+*").join(" ") +} + export type UpdateQuestionInput = { type: QuestionType difficulty: number @@ -36,6 +49,16 @@ export type GetQuestionsParams = { difficulty?: number; }; +/** + * 分页查询题目列表,支持按 ID 集合、关键词全文检索、题目类型/难度、 + * 知识点/章节/教材级联筛选。 + * + * 级联筛选通过 textbooks 模块 data-access 解析知识点 ID 集合,避免直接查询 + * textbooks/chapters/knowledgePoints 表。子题(parentId 非 null)默认不返回, + * 除非通过 `ids` 显式指定。 + * + * @returns 包含 data 与 meta(page/pageSize/total/totalPages)的对象 + */ export const getQuestionsRaw = async ({ q, page = 1, @@ -58,10 +81,13 @@ export const getQuestionsRaw = async ({ } if (q && q.trim().length > 0) { - const needle = `%${q.trim().toLowerCase()}%`; - conditions.push( - sql`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}` - ); + // F-02: 使用 FULLTEXT 索引(content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描 + const booleanQuery = toBooleanModeQuery(q.trim()) + if (booleanQuery) { + conditions.push( + sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)` + ) + } } if (type) { @@ -201,6 +227,11 @@ export type QuestionsDashboardStats = { questionCount: number } +/** + * 获取题目仪表盘统计(题目总数)。 + * + * @returns 包含 questionCount 的统计对象 + */ export const getQuestionsDashboardStatsRaw = async (): Promise => { const [row] = await db.select({ value: count() }).from(questions) return { questionCount: Number(row?.value ?? 0) } @@ -246,6 +277,16 @@ async function insertQuestionWithRelations( return newQuestionId; } +/** + * 创建题目(含子题与知识点关联),在单事务内递归插入。 + * + * 递归遍历 `input.subQuestions` 创建子题,parentId 指向父题 ID。 + * 任一子题插入失败则整个事务回滚。 + * + * @param input - 题目输入(content/type/difficulty/knowledgePointIds/subQuestions) + * @param authorId - 创建者用户 ID + * @returns 新创建的根题目 ID + */ export async function createQuestionWithRelations( input: CreateQuestionInput, authorId: string @@ -255,6 +296,17 @@ export async function createQuestionWithRelations( }); } +/** + * 按 ID 更新题目(content/type/difficulty)并替换知识点关联。 + * + * 在单事务内:1) 更新题目字段;2) 删除原 questionsToKnowledgePoints 关联; + * 3) 插入新关联。权限范围由 `canEditAll` 控制——非 all 范围只能更新自己创建的题目。 + * + * @param id - 待更新的题目 ID + * @param input - 更新输入(type/difficulty/content/knowledgePointIds) + * @param canEditAll - 是否有全部数据范围权限 + * @param authorId - 当前用户 ID(用于权限过滤) + */ export async function updateQuestionById( id: string, input: UpdateQuestionInput, @@ -291,29 +343,68 @@ export async function updateQuestionById( }); } +/** + * 批量收集给定根题目 ID 的所有后代(含自身)ID。 + * + * 使用 BFS 逐层 `inArray` 查询替代原先每节点逐条 SELECT 的 N+1 模式: + * 每层只发 1 条 SQL,整体复杂度从 O(N) 降至 O(depth)。 + * `visited` 跨调用共享以避免循环引用造成的无限递归。 + */ +async function collectDescendantQuestionIds( + tx: Tx, + rootIds: string[], + visited: Set = new Set(), +): Promise { + const queue: string[] = [] + for (const id of rootIds) { + if (!visited.has(id)) { + visited.add(id) + queue.push(id) + } + } + + while (queue.length > 0) { + const currentLevel = queue.splice(0, queue.length) + const children = await tx + .select({ id: questions.id }) + .from(questions) + .where(inArray(questions.parentId, currentLevel)); + + for (const child of children) { + if (!visited.has(child.id)) { + visited.add(child.id) + queue.push(child.id) + } + } + } + + return Array.from(visited) +} + async function deleteQuestionRecursive( tx: Tx, questionId: string, visited: Set = new Set(), ): Promise { - if (visited.has(questionId)) { - // 环检测:避免在异常数据(如循环引用)下无限递归 - return - } - visited.add(questionId) - - const children = await tx - .select({ id: questions.id }) - .from(questions) - .where(eq(questions.parentId, questionId)); - - for (const child of children) { - await deleteQuestionRecursive(tx, child.id, visited); - } - - await tx.delete(questions).where(eq(questions.id, questionId)); + // 收集所有后代 ID(含自身),单条 inArray 批量删除, + // 替代原先每层每子节点逐条 SELECT + DELETE 的 N+1 模式。 + // 环检测由 collectDescendantQuestionIds 内部的 visited Set 保证。 + const allIds = await collectDescendantQuestionIds(tx, [questionId], visited) + if (allIds.length === 0) return + await tx.delete(questions).where(inArray(questions.id, allIds)) } +/** + * 按 ID 递归删除题目(含所有子题)。 + * + * 在事务内:1) 查询题目并校验存在性;2) 校验权限(非 all 范围只能删除自己创建的题目); + * 3) 通过 BFS 收集所有后代 ID 后单条 `inArray` 批量删除。 + * + * @param questionId - 待删除的根题目 ID + * @param canDeleteAll - 是否有全部数据范围权限 + * @param authorId - 当前用户 ID(用于权限过滤) + * @throws 当题目不存在或无权删除时抛出 Error + */ export async function deleteQuestionByIdRecursive( questionId: string, canDeleteAll: boolean, @@ -369,26 +460,35 @@ export async function deleteQuestionsBatch( const targetIds = targetRows.map((r) => r.id) if (targetIds.length === 0) return 0 - for (const id of targetIds) { - await deleteQuestionRecursive(tx, id) + // 一次性收集所有 targetIds 的后代(含自身),单条 inArray 删除, + // 替代原先循环调用 deleteQuestionRecursive 产生的 N×depth 查询。 + const visited = new Set() + const allIds = await collectDescendantQuestionIds(tx, targetIds, visited) + if (allIds.length > 0) { + await tx.delete(questions).where(inArray(questions.id, allIds)) } return targetIds.length }) } -export async function getKnowledgePointOptions(): Promise { +export async function getKnowledgePointOptionsRaw(): Promise { // Delegate to textbooks module data-access to avoid direct queries on // textbooks/chapters/knowledgePoints tables (owned by textbooks module). return await getKnowledgePointOptionsFromTextbooks() } +export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, { + tags: ["questions"], + ttl: 600, + keyParts: ["questions", "getKnowledgePointOptions"], +}) /** * 获取教材选项列表(级联筛选第一级)。 * * 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。 */ -export async function getTextbookOptions(): Promise { +export async function getTextbookOptionsRaw(): Promise { const textbooks = await getTextbooksFromTextbooks() return textbooks.map((tb) => ({ id: tb.id, @@ -397,13 +497,18 @@ export async function getTextbookOptions(): Promise { grade: tb.grade, })) } +export const getTextbookOptions = cacheFn(getTextbookOptionsRaw, { + tags: ["questions"], + ttl: 600, + keyParts: ["questions", "getTextbookOptions"], +}) /** * 获取指定教材下的章节选项列表(级联筛选第二级)。 * * 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。 */ -export async function getChapterOptions(textbookId: string): Promise { +export async function getChapterOptionsRaw(textbookId: string): Promise { const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId) const options: ChapterOption[] = [] @@ -424,16 +529,26 @@ export async function getChapterOptions(textbookId: string): Promise { +export async function getKnowledgePointOptionsByChapterRaw(chapterId: string): Promise<{ id: string; name: string }[]> { const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) return kps.map((kp) => ({ id: kp.id, name: kp.name })) } +export const getKnowledgePointOptionsByChapter = cacheFn(getKnowledgePointOptionsByChapterRaw, { + tags: ["questions"], + ttl: 600, + keyParts: ["questions", "getKnowledgePointOptionsByChapter"], +}) // --------------------------------------------------------------------------- // Cross-module query interfaces — read-only access for other modules @@ -453,22 +568,28 @@ export const getKnowledgePointsForQuestionsRaw = async ( const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) if (uniqueIds.length === 0) return result + // 1. 查询 questionId -> knowledgePointId 关联(questions 模块自有表) const rows = await db .select({ questionId: questionsToKnowledgePoints.questionId, - knowledgePointId: knowledgePoints.id, - knowledgePointName: knowledgePoints.name, + knowledgePointId: questionsToKnowledgePoints.knowledgePointId, }) .from(questionsToKnowledgePoints) - .innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId)) .where(inArray(questionsToKnowledgePoints.questionId, uniqueIds)) + if (rows.length === 0) return result + + // 2. 跨模块批量解析知识点名称,避免直接 JOIN knowledgePoints 表 + const kpIds = Array.from(new Set(rows.map((r) => r.knowledgePointId))) + const kpNameMap = await getKnowledgePointNamesByIds(kpIds) + + // 3. 组装结果 for (const r of rows) { const list = result.get(r.questionId) ?? [] list.push({ questionId: r.questionId, knowledgePointId: r.knowledgePointId, - knowledgePointName: r.knowledgePointName, + knowledgePointName: kpNameMap.get(r.knowledgePointId) ?? "", }) result.set(r.questionId, list) } @@ -574,7 +695,7 @@ export interface QuestionExportItem { * 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。 * 遵循权限范围:非 all 范围只能导出自己创建的题目。 */ -export async function exportQuestions( +export async function exportQuestionsRaw( questionIds?: string[], canExportAll = true, authorId?: string @@ -620,6 +741,11 @@ export async function exportQuestions( updatedAt: row.updatedAt, })) } +export const exportQuestions = cacheFn(exportQuestionsRaw, { + tags: ["questions"], + ttl: 60, + keyParts: ["questions", "exportQuestions"], +}) /** 导入题目的单条输入 */ export interface QuestionImportItem { @@ -660,3 +786,42 @@ export async function importQuestions( return createdIds }) } + +// --------------------------------------------------------------------------- +// Cross-module batch interfaces — question count per knowledge point +// --------------------------------------------------------------------------- + +/** + * 跨模块接口:按知识点 ID 批量获取关联题目数。 + * + * 供 textbooks 模块知识图谱使用,避免直接查询 questionsToKnowledgePoints 表。 + * + * @param knowledgePointIds 知识点 ID 列表 + * @returns Map + */ +export const getQuestionCountByKpIdsRaw = async ( + knowledgePointIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ + knowledgePointId: questionsToKnowledgePoints.knowledgePointId, + count: count(), + }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.knowledgePointId, uniqueIds)) + .groupBy(questionsToKnowledgePoints.knowledgePointId) + + for (const r of rows) { + result.set(r.knowledgePointId, Number(r.count)) + } + return result +} +export const getQuestionCountByKpIds = cacheFn(getQuestionCountByKpIdsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestionCountByKpIds"], +}) diff --git a/src/modules/rbac/actions.ts b/src/modules/rbac/actions.ts index 45fb795..2f21e1f 100644 --- a/src/modules/rbac/actions.ts +++ b/src/modules/rbac/actions.ts @@ -4,7 +4,7 @@ import { after } from "next/server" import type { ActionState } from "@/shared/types/action-state" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" -import { Permissions, type Permission } from "@/shared/types/permissions" +import { Permissions, type Permission, isPermission } from "@/shared/types/permissions" import { logAudit } from "@/shared/lib/audit-logger" import { logDataChange } from "@/shared/lib/change-logger" import { invalidateFor } from "@/shared/lib/cache" @@ -248,8 +248,8 @@ export async function setRolePermissionsAction( } const before = await getRolePermissions(parsed.data.roleId) - // Schema validates all strings are valid permission values, so the cast is safe. - const permissions = parsed.data.permissions as Permission[] + // Schema 已用 refine 校验所有字符串均为合法权限值,再用类型守卫收窄到 Permission[] + const permissions = parsed.data.permissions.filter(isPermission) await setRolePermissions(parsed.data.roleId, permissions) trackPermissionChange({ diff --git a/src/modules/rbac/lib/permission-catalog.ts b/src/modules/rbac/lib/permission-catalog.ts index 20fa03d..258c6c8 100644 --- a/src/modules/rbac/lib/permission-catalog.ts +++ b/src/modules/rbac/lib/permission-catalog.ts @@ -128,6 +128,8 @@ export const PERMISSION_CATALOG: PermissionGroup[] = [ labelKey: "rbac:permissions.group.audit", permissions: [ { key: "AUDIT_LOG_READ", value: Permissions.AUDIT_LOG_READ, labelKey: "rbac:permissions.audit.read", descriptionKey: "rbac:permissions.audit.readDesc" }, + { key: "AUDIT_LOG_PURGE", value: Permissions.AUDIT_LOG_PURGE, labelKey: "rbac:permissions.audit.purge", descriptionKey: "rbac:permissions.audit.purgeDesc" }, + { key: "AUDIT_RETENTION_MANAGE", value: Permissions.AUDIT_RETENTION_MANAGE, labelKey: "rbac:permissions.audit.retentionManage", descriptionKey: "rbac:permissions.audit.retentionManageDesc" }, ], }, {