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
This commit is contained in:
SpecialX
2026-07-07 16:21:00 +08:00
parent 524ecade19
commit 783b8f5484
26 changed files with 484 additions and 1285 deletions

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { ArrowLeft, Check, Loader2, Save, Send } from "lucide-react" import { ArrowLeft, Check, Loader2, Save, Send } from "lucide-react"
@@ -110,7 +110,7 @@ export function MessageCompose({
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (!receiverId) { if (!receiverId) {
toast.error(t("form.selectRecipient")) notify.error(t("form.selectRecipient"))
return return
} }
// P0-5: 标记已提交,阻止后续自动保存 effect 触发新草稿创建 // P0-5: 标记已提交,阻止后续自动保存 effect 触发新草稿创建
@@ -129,7 +129,7 @@ export function MessageCompose({
if (draftIdRef.current) { if (draftIdRef.current) {
void deleteMessageDraftAction(draftIdRef.current) void deleteMessageDraftAction(draftIdRef.current)
} }
toast.success(res.message) notify.success(res.message)
router.push("/messages") router.push("/messages")
router.refresh() router.refresh()
} else { } else {
@@ -137,12 +137,12 @@ export function MessageCompose({
if (res.errors) { if (res.errors) {
setFieldErrors(res.errors) setFieldErrors(res.errors)
} }
toast.error(res.message || t("messages.sendFailed")) notify.error(res.message || t("messages.sendFailed"))
// 提交失败时重置 submittedRef 允许后续自动保存 // 提交失败时重置 submittedRef 允许后续自动保存
submittedRef.current = false submittedRef.current = false
} }
} catch { } catch {
toast.error(t("messages.sendFailed")) notify.error(t("messages.sendFailed"))
submittedRef.current = false submittedRef.current = false
} finally { } finally {
setIsWorking(false) setIsWorking(false)
@@ -163,14 +163,14 @@ export function MessageCompose({
if (res.success && res.data) { if (res.success && res.data) {
setDraftId(res.data) setDraftId(res.data)
setSaveStatus("saved") setSaveStatus("saved")
toast.success(t("messages.draftSaved")) notify.success(t("messages.draftSaved"))
} else { } else {
setSaveStatus("idle") setSaveStatus("idle")
toast.error(res.message) notify.error(res.message)
} }
} catch { } catch {
setSaveStatus("idle") setSaveStatus("idle")
toast.error(t("messages.sendFailed")) notify.error(t("messages.sendFailed"))
} }
} }

View File

@@ -3,7 +3,7 @@
import { useEffect, useState, useOptimistic, useTransition } from "react" import { useEffect, useState, useOptimistic, useTransition } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { ArrowLeft, Loader2, Mail, Reply, RotateCcw, Star, Trash2 } from "lucide-react" import { ArrowLeft, Loader2, Mail, Reply, RotateCcw, Star, Trash2 } from "lucide-react"
@@ -98,14 +98,14 @@ export function MessageDetail({
try { try {
const res = await deleteMessageAction(message.id) const res = await deleteMessageAction(message.id)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
router.push("/messages") router.push("/messages")
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("messages.deleteFailed")) notify.error(res.message || t("messages.deleteFailed"))
} }
} catch { } catch {
toast.error(t("messages.deleteFailed")) notify.error(t("messages.deleteFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
setDeleteOpen(false) setDeleteOpen(false)
@@ -120,14 +120,14 @@ export function MessageDetail({
try { try {
const res = await toggleMessageStarAction(message.id) const res = await toggleMessageStarAction(message.id)
if (res.success) { if (res.success) {
toast.success(t("messages.starToggled")) notify.success(t("messages.starToggled"))
// 同步数据源,让 useOptimistic 回滚到最新服务端值 // 同步数据源,让 useOptimistic 回滚到最新服务端值
router.refresh() router.refresh()
} else { } else {
toast.error(res.message) notify.error(res.message)
} }
} catch { } catch {
toast.error(t("messages.sendFailed")) notify.error(t("messages.sendFailed"))
} }
}) })
} }
@@ -140,18 +140,18 @@ export function MessageDetail({
if (res.success) { if (res.success) {
// 同步 UI立即显示"已撤回"占位 // 同步 UI立即显示"已撤回"占位
setIsRecalled(true) setIsRecalled(true)
toast.success(t("messages.recalled")) notify.success(t("messages.recalled"))
router.refresh() router.refresh()
} else { } else {
// 失败:根据返回消息判断是否超时 // 失败:根据返回消息判断是否超时
if (res.message === "Recall window expired") { if (res.message === "Recall window expired") {
toast.error(t("messages.recallExpired")) notify.error(t("messages.recallExpired"))
} else { } else {
toast.error(res.message || t("messages.recallFailed")) notify.error(res.message || t("messages.recallFailed"))
} }
} }
} catch { } catch {
toast.error(t("messages.recallFailed")) notify.error(t("messages.recallFailed"))
} finally { } finally {
setIsRecalling(false) setIsRecalling(false)
setRecallOpen(false) setRecallOpen(false)

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { FileText, Loader2, Trash2 } from "lucide-react" import { FileText, Loader2, Trash2 } from "lucide-react"
@@ -38,13 +38,13 @@ export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) {
if (!res.success) { if (!res.success) {
// 回滚 // 回滚
setItems(snapshot) setItems(snapshot)
toast.error(res.message) notify.error(res.message)
} else { } else {
toast.success(t("messages.draftDeleted")) notify.success(t("messages.draftDeleted"))
} }
} catch { } catch {
setItems(snapshot) setItems(snapshot)
toast.error(t("messages.sendFailed")) notify.error(t("messages.sendFailed"))
} finally { } finally {
setDeletingId(null) setDeletingId(null)
setDeleteOpen(null) setDeleteOpen(null)

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { ArrowLeft, Send, Loader2 } from "lucide-react" import { ArrowLeft, Send, Loader2 } from "lucide-react"
@@ -62,7 +62,7 @@ export function MessageGroupCompose({
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (!classId) { if (!classId) {
toast.error(t("form.selectClass")) notify.error(t("form.selectClass"))
return return
} }
formData.set("classId", classId) formData.set("classId", classId)
@@ -73,25 +73,25 @@ export function MessageGroupCompose({
const res = await sendGroupMessageAction(null, formData) const res = await sendGroupMessageAction(null, formData)
if (res.success) { if (res.success) {
const count = res.data?.recipientCount ?? 0 const count = res.data?.recipientCount ?? 0
toast.success(t("messages.groupSent", { count })) notify.success(t("messages.groupSent", { count }))
router.push("/messages") router.push("/messages")
router.refresh() router.refresh()
} else { } else {
// 识别业务层返回的特定错误(无收件人 / 越权) // 识别业务层返回的特定错误(无收件人 / 越权)
const msg = res.message ?? "" const msg = res.message ?? ""
if (msg.includes("No recipients")) { if (msg.includes("No recipients")) {
toast.error(t("messages.groupNoRecipients")) notify.error(t("messages.groupNoRecipients"))
} else if (msg.includes("Not allowed")) { } else if (msg.includes("Not allowed")) {
toast.error(t("messages.groupNotAuthorized")) notify.error(t("messages.groupNotAuthorized"))
} else { } else {
toast.error(t("messages.groupSendFailed")) notify.error(t("messages.groupSendFailed"))
} }
if (res.errors) { if (res.errors) {
setFieldErrors(res.errors) setFieldErrors(res.errors)
} }
} }
} catch { } catch {
toast.error(t("messages.groupSendFailed")) notify.error(t("messages.groupSendFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }

View File

@@ -5,7 +5,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react" import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -139,7 +139,7 @@ export function MessageList({
try { try {
const res = await messageService.toggleStar(messageId) const res = await messageService.toggleStar(messageId)
if (res.success) { if (res.success) {
toast.success(t("messages.starToggled")) notify.success(t("messages.starToggled"))
// P1-2: 当前在 starred Tab 时重新加载星标列表 // P1-2: 当前在 starred Tab 时重新加载星标列表
if (tab === "starred") { if (tab === "starred") {
void loadStarred() void loadStarred()
@@ -147,10 +147,10 @@ export function MessageList({
// 同步数据源,让 useOptimistic 回滚到最新服务端值 // 同步数据源,让 useOptimistic 回滚到最新服务端值
await router.refresh() await router.refresh()
} else { } else {
toast.error(res.message) notify.error(res.message)
} }
} catch { } catch {
toast.error(t("messages.sendFailed")) notify.error(t("messages.sendFailed"))
} finally { } finally {
setTogglingStarId(null) setTogglingStarId(null)
} }
@@ -170,16 +170,16 @@ export function MessageList({
if (res.success) { if (res.success) {
// 同步 UI立即显示"已撤回"占位 // 同步 UI立即显示"已撤回"占位
setRecalledOverride((prev) => ({ ...prev, [message.id]: true })) setRecalledOverride((prev) => ({ ...prev, [message.id]: true }))
toast.success(t("messages.recalled")) notify.success(t("messages.recalled"))
} else { } else {
if (res.message === "Recall window expired") { if (res.message === "Recall window expired") {
toast.error(t("messages.recallExpired")) notify.error(t("messages.recallExpired"))
} else { } else {
toast.error(res.message || t("messages.recallFailed")) notify.error(res.message || t("messages.recallFailed"))
} }
} }
} catch { } catch {
toast.error(t("messages.recallFailed")) notify.error(t("messages.recallFailed"))
} finally { } finally {
setRecallingId(null) setRecallingId(null)
} }

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Flag, Ban, Loader2 } from "lucide-react" import { Flag, Ban, Loader2 } from "lucide-react"
@@ -65,22 +65,22 @@ export function MessageReportBlock({
try { try {
const res = await reportMessageAction(null, formData) const res = await reportMessageAction(null, formData)
if (res.success) { if (res.success) {
toast.success(t("messages.reported")) notify.success(t("messages.reported"))
setReportOpen(false) setReportOpen(false)
setDescription("") setDescription("")
setReason("spam") setReason("spam")
} else { } else {
// 识别已举报 // 识别已举报
if (res.message?.includes("Already reported")) { if (res.message?.includes("Already reported")) {
toast.error(t("messages.alreadyReported")) notify.error(t("messages.alreadyReported"))
} else if (res.message?.includes("own message")) { } else if (res.message?.includes("own message")) {
toast.error(t("messages.reportSelf")) notify.error(t("messages.reportSelf"))
} else { } else {
toast.error(res.message || t("messages.reportFailed")) notify.error(res.message || t("messages.reportFailed"))
} }
} }
} catch { } catch {
toast.error(t("messages.reportFailed")) notify.error(t("messages.reportFailed"))
} finally { } finally {
setIsReporting(false) setIsReporting(false)
} }
@@ -93,20 +93,20 @@ export function MessageReportBlock({
formData.set("blockedId", senderId) formData.set("blockedId", senderId)
const res = await blockUserAction(null, formData) const res = await blockUserAction(null, formData)
if (res.success) { if (res.success) {
toast.success(t("messages.blocked")) notify.success(t("messages.blocked"))
setBlockOpen(false) setBlockOpen(false)
router.refresh() router.refresh()
} else { } else {
if (res.message?.includes("already")) { if (res.message?.includes("already")) {
toast.error(t("messages.alreadyBlocked")) notify.error(t("messages.alreadyBlocked"))
} else if (res.message?.includes("yourself")) { } else if (res.message?.includes("yourself")) {
toast.error(t("messages.blockSelf")) notify.error(t("messages.blockSelf"))
} else { } else {
toast.error(res.message || t("messages.blockFailed")) notify.error(res.message || t("messages.blockFailed"))
} }
} }
} catch { } catch {
toast.error(t("messages.blockFailed")) notify.error(t("messages.blockFailed"))
} finally { } finally {
setIsBlocking(false) setIsBlocking(false)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { ChevronDown, FileText, Plus, Trash2 } from "lucide-react" import { ChevronDown, FileText, Plus, Trash2 } from "lucide-react"
@@ -78,7 +78,7 @@ export function MessageTemplatePicker({
const handleInsert = (template: MessageTemplate): void => { const handleInsert = (template: MessageTemplate): void => {
onInsert(template.content) onInsert(template.content)
toast.success(t("templates.insert")) notify.success(t("templates.insert"))
} }
const openCreate = (): void => { const openCreate = (): void => {
@@ -102,14 +102,14 @@ export function MessageTemplatePicker({
try { try {
const res = await saveMessageTemplateAction(null, formData) const res = await saveMessageTemplateAction(null, formData)
if (res.success) { if (res.success) {
toast.success(t("templates.saved")) notify.success(t("templates.saved"))
setManageOpen(false) setManageOpen(false)
await loadTemplates() await loadTemplates()
} else { } else {
toast.error(res.message) notify.error(res.message)
} }
} catch { } catch {
toast.error(t("templates.saved")) notify.error(t("templates.saved"))
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
@@ -120,13 +120,13 @@ export function MessageTemplatePicker({
try { try {
const res = await deleteMessageTemplateAction(templateId) const res = await deleteMessageTemplateAction(templateId)
if (res.success) { if (res.success) {
toast.success(t("templates.deleted")) notify.success(t("templates.deleted"))
await loadTemplates() await loadTemplates()
} else { } else {
toast.error(res.message) notify.error(res.message)
} }
} catch { } catch {
toast.error(t("templates.deleted")) notify.error(t("templates.deleted"))
} finally { } finally {
setDeletingId(null) setDeletingId(null)
} }

View File

@@ -89,7 +89,7 @@ export function UnreadMessageBadge() {
// 兜底:如果 SSE 在 5 秒内未收到任何数据,启动轮询作为保险 // 兜底:如果 SSE 在 5 秒内未收到任何数据,启动轮询作为保险
const fallbackTimer = setTimeout(() => { const fallbackTimer = setTimeout(() => {
if (active && usingSSE && count === 0) { if (active && usingSSE) {
// SSE 可能未推送初始数据,拉取一次 // SSE 可能未推送初始数据,拉取一次
void fetchCount() void fetchCount()
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react" import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
@@ -49,13 +49,13 @@ export function NotificationList({ notifications }: { notifications: Notificatio
try { try {
const res = await markAllNotificationsAsReadAction() const res = await markAllNotificationsAsReadAction()
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("messages.markReadFailed")) notify.error(res.message || t("messages.markReadFailed"))
} }
} catch { } catch {
toast.error(t("messages.markReadFailed")) notify.error(t("messages.markReadFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -68,7 +68,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
router.refresh() router.refresh()
} }
} catch { } catch {
toast.error(t("messages.markReadFailed")) notify.error(t("messages.markReadFailed"))
} }
} }
@@ -79,7 +79,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
router.refresh() router.refresh()
} }
} catch { } catch {
toast.error(t("messages.archiveFailed")) notify.error(t("messages.archiveFailed"))
} }
} }

View File

@@ -23,6 +23,7 @@ import { and, count, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { messageNotifications, notificationLogs, users } from "@/shared/db/schema" import { messageNotifications, notificationLogs, users } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache" import { cacheFn } from "@/shared/lib/cache"
import { serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
import { createModuleLogger } from "@/shared/lib/logger" import { createModuleLogger } from "@/shared/lib/logger"
import type { ChannelRecipient } from "./channels/types" import type { ChannelRecipient } from "./channels/types"
import type { import type {
@@ -36,7 +37,6 @@ import type {
} from "./types" } from "./types"
const log = createModuleLogger("notifications") const log = createModuleLogger("notifications")
const toIsoRequired = (d: Date): string => d.toISOString()
const isNotificationType = (v: unknown): v is NotificationType => const isNotificationType = (v: unknown): v is NotificationType =>
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic" v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"

View File

@@ -158,7 +158,9 @@ export function useNotificationStream(options?: UseNotificationStreamOptions): U
setIsUsingFallback(true) setIsUsingFallback(true)
return 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 (data.type === "update") {
if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount) if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount)
if (data.notifications) setNotifications(data.notifications) if (data.notifications) setNotifications(data.notifications)

View File

@@ -19,13 +19,12 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { notificationPreferences } from "@/shared/db/schema" import { notificationPreferences } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache" import { cacheFn } from "@/shared/lib/cache"
import { serializeDateRequired as toIso } from "@/shared/lib/date-utils"
import type { import type {
NotificationPreferences, NotificationPreferences,
UpdateNotificationPreferencesInput, UpdateNotificationPreferencesInput,
} from "./types" } from "./types"
const toIso = (d: Date): string => d.toISOString()
const mapRow = ( const mapRow = (
row: typeof notificationPreferences.$inferSelect row: typeof notificationPreferences.$inferSelect
): NotificationPreferences => ({ ): NotificationPreferences => ({

View File

@@ -1,7 +1,5 @@
"use server" "use server"
import { eq } from "drizzle-orm"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { requireAuth } from "@/shared/lib/auth-guard" import { requireAuth } from "@/shared/lib/auth-guard"
import { invalidateFor } from "@/shared/lib/cache" import { invalidateFor } from "@/shared/lib/cache"
@@ -12,8 +10,6 @@ import {
rateLimitKey, rateLimitKey,
RATE_LIMIT_RULES, RATE_LIMIT_RULES,
} from "@/shared/lib/rate-limit" } from "@/shared/lib/rate-limit"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { import {
enrollStudentByInvitationCode, enrollStudentByInvitationCode,
enrollTeacherByInvitationCode, enrollTeacherByInvitationCode,
@@ -22,11 +18,18 @@ import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
import { OnboardingSchema } from "./schema" import { OnboardingSchema } from "./schema"
import { import {
getOnboardingStatus, getOnboardingStatus,
getUserOnboardedAt,
markUserOnboarded,
updateUserProfile, updateUserProfile,
bindParentToChild, bindParentToChild,
} from "./data-access" } from "./data-access"
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types" 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 状态。 * 查询当前用户 onboarding 状态。
* 供服务端组件 / 客户端组件读取,决定是否渲染引导流程。 * 供服务端组件 / 客户端组件读取,决定是否渲染引导流程。
@@ -69,13 +72,10 @@ export async function completeOnboardingAction(
const userId = ctx.userId const userId = ctx.userId
// P0-5 服务端幂等:已完成的用户直接返回成功,防止双击或刷新重复提交 // P0-5 服务端幂等:已完成的用户直接返回成功,防止双击或刷新重复提交
const [existingUser] = await db // 审计 v1 P0 修复G5-002改调 data-access 函数,消除 actions 直查 DB
.select({ onboardedAt: users.onboardedAt }) const existingOnboardedAt = await getUserOnboardedAt(userId)
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (existingUser?.onboardedAt) { if (existingOnboardedAt) {
const roleNames = ctx.roles const roleNames = ctx.roles
return { return {
success: true, success: true,
@@ -108,7 +108,7 @@ export async function completeOnboardingAction(
// 教师任课科目过滤:仅保留系统默认科目 // 教师任课科目过滤:仅保留系统默认科目
const validTeacherSubjects = input.teacherSubjects.filter( const validTeacherSubjects = input.teacherSubjects.filter(
(s): s is (typeof DEFAULT_CLASS_SUBJECTS)[number] => (s): s is (typeof DEFAULT_CLASS_SUBJECTS)[number] =>
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s) CLASS_SUBJECTS_READONLY.includes(s)
) )
// audit-P1-8家长绑定子女独立速率限制。 // audit-P1-8家长绑定子女独立速率限制。
@@ -144,20 +144,38 @@ export async function completeOnboardingAction(
// 收集局部失败项P1-2班级码/子女绑定失败不回滚整个事务 // 收集局部失败项P1-2班级码/子女绑定失败不回滚整个事务
const failures: OnboardingFailureItem[] = [] const failures: OnboardingFailureItem[] = []
// 事务包裹全部写入 // 审计 v1 P0 修复G5-002移除 db.transaction 包裹,消除 actions 对 db 的直接依赖。
const result = await db.transaction(async (tx) => { // 原 db.transaction 实际上只保护 onboardedAt 的更新(其他 data-access 函数均用 db 不在事务内),
// 1. 更新基础资料 // 移除后行为一致。所有写操作通过 data-access 函数完成。
await updateUserProfile(userId, { // 1. 更新基础资料
name: input.name, await updateUserProfile(userId, {
phone: input.phone, name: input.name,
address: input.address, phone: input.phone,
}) address: input.address,
})
// 2. 学生:通过邀请码绑定班级(调用 classes data-access含校验 // 2. 学生:通过邀请码绑定班级(调用 classes data-access含校验
if (normalizedRoles.includes("student")) { if (normalizedRoles.includes("student")) {
for (const code of input.classCodes) { 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 { try {
await enrollStudentByInvitationCode(userId, code) await enrollTeacherByInvitationCode(userId, code, null)
} catch (e) { } catch (e) {
failures.push({ failures.push({
type: "class_code", type: "class_code",
@@ -165,16 +183,11 @@ export async function completeOnboardingAction(
message: e instanceof Error ? e.message : "班级码绑定失败", message: e instanceof Error ? e.message : "班级码绑定失败",
}) })
} }
} } else {
} // 指定科目:循环为每个科目绑定(修复 P0-3 UI 多选但服务端只取第一个的 bug
for (const subject of validTeacherSubjects) {
// 3. 教师通过邀请码绑定任课P0-3 修复:循环为每个科目绑定,不再只取第一个)
if (normalizedRoles.includes("teacher")) {
for (const code of input.classCodes) {
if (validTeacherSubjects.length === 0) {
// 未指定科目:调用一次让 data-access 自动分配空位科目
try { try {
await enrollTeacherByInvitationCode(userId, code, null) await enrollTeacherByInvitationCode(userId, code, subject)
} catch (e) { } catch (e) {
failures.push({ failures.push({
type: "class_code", type: "class_code",
@@ -182,51 +195,35 @@ export async function completeOnboardingAction(
message: e instanceof Error ? e.message : "班级码绑定失败", 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 // 4. 家长绑定子女支持多子女循环绑定P1-4
if (normalizedRoles.includes("parent") && input.children.length > 0) { if (normalizedRoles.includes("parent") && input.children.length > 0) {
for (const child of input.children) { for (const child of input.children) {
const bindResult = await bindParentToChild({ const bindResult = await bindParentToChild({
parentId: userId, parentId: userId,
childEmail: child.childEmail, childEmail: child.childEmail,
childBirthDate: child.childBirthDate, childBirthDate: child.childBirthDate,
childPhoneSuffix: child.childPhoneSuffix, childPhoneSuffix: child.childPhoneSuffix,
relation: child.childRelation, 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事务内,确保原子性 // 5. 最后标记 onboarded审计 v1 P0 修复:改调 data-access 函数
await tx await markUserOnboarded(userId)
.update(users)
.set({ onboardedAt: new Date() })
.where(eq(users.id, userId))
return { defaultPath: resolveDefaultPath(normalizedRoles) } const result = { defaultPath: resolveDefaultPath(normalizedRoles) }
})
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross // P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross
await logAudit({ await logAudit({

View File

@@ -1,3 +1,5 @@
import "server-only"
import { eq, and } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -49,6 +51,47 @@ export const getOnboardingStatus = cacheFn(getOnboardingStatusRaw, {
keyParts: ["onboarding", "status"], 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<Date | null> {
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<void> {
await db
.update(users)
.set({ onboardedAt: new Date() })
.where(eq(users.id, userId))
}
/** /**
* 更新用户基础资料(姓名/电话/住址)。 * 更新用户基础资料(姓名/电话/住址)。
* 不涉及角色与班级绑定,独立可复用。 * 不涉及角色与班级绑定,独立可复用。

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { type ClassSubject } from "@/modules/classes/types" import { type ClassSubject } from "@/modules/classes/types"
import { completeOnboardingAction } from "@/modules/onboarding/actions" import { completeOnboardingAction } from "@/modules/onboarding/actions"
@@ -96,11 +96,11 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
const onNext = () => { const onNext = () => {
if (currentStepId === "basicInfo" && !canNext) { if (currentStepId === "basicInfo" && !canNext) {
toast.error(t("validation.needNamePhone")) notify.error(t("validation.needNamePhone"))
return return
} }
if (currentStepId === "roleInfo" && isParent && !canNext) { if (currentStepId === "roleInfo" && isParent && !canNext) {
toast.error(t("validation.needOneChild")) notify.error(t("validation.needOneChild"))
return return
} }
goToStep(step + 1) goToStep(step + 1)
@@ -133,7 +133,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(), (c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(),
) )
if (validChildren.length === 0) { if (validChildren.length === 0) {
toast.error(t("validation.needOneChild")) notify.error(t("validation.needOneChild"))
return return
} }
} }
@@ -154,14 +154,14 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
const result = await completeOnboardingAction(null, formData) const result = await completeOnboardingAction(null, formData)
if (!result.success) { if (!result.success) {
toast.error(result.message ?? t("toast.submitFailed")) notify.error(result.message ?? t("toast.submitFailed"))
return return
} }
if (result.message && result.message.includes("绑定失败")) { if (result.message && result.message.includes("绑定失败")) {
toast.warning(result.message) notify.warning(result.message)
} else { } else {
toast.success(t("toast.completeSuccess")) notify.success(t("toast.completeSuccess"))
} }
await update?.() await update?.()
@@ -170,7 +170,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
router.refresh() router.refresh()
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : t("toast.submitFailed") const msg = e instanceof Error ? e.message : t("toast.submitFailed")
toast.error(msg) notify.error(msg)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

@@ -37,7 +37,10 @@ export function ChildScheduleCard({
if (!grouped[key]) grouped[key] = [] if (!grouped[key]) grouped[key] = []
grouped[key].push(item) 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 ( return (
<Card> <Card>

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Download, Loader2 } from "lucide-react" import { Download, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -30,7 +30,7 @@ export function ParentExportButton({
const result = await safeActionCall( const result = await safeActionCall(
() => exportGradesAction({ studentId }), () => exportGradesAction({ studentId }),
{ {
onError: () => toast.error(`Failed to export grades for ${studentName}.`), onError: () => notify.error(`Failed to export grades for ${studentName}.`),
onFinally: () => setIsExporting(false), onFinally: () => setIsExporting(false),
} }
) )
@@ -53,12 +53,12 @@ export function ParentExportButton({
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
toast.success(`Grades exported for ${studentName}.`) notify.success(`Grades exported for ${studentName}.`)
} catch { } catch {
toast.error("Failed to download the export file.") notify.error("Failed to download the export file.")
} }
} else if (result) { } else if (result) {
toast.error(result.message || `Failed to export grades for ${studentName}.`) notify.error(result.message || `Failed to export grades for ${studentName}.`)
} }
} }

View File

@@ -29,6 +29,9 @@ import type {
} from "./types" } from "./types"
import { ABNORMAL_EVENT_THRESHOLD } from "./types" import { ABNORMAL_EVENT_THRESHOLD } from "./types"
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
const DEFAULT_LIMIT = 1000
const ALL_EVENT_TYPES: ProctoringEventType[] = [ const ALL_EVENT_TYPES: ProctoringEventType[] = [
"tab_switch", "tab_switch",
"window_blur", "window_blur",
@@ -146,6 +149,7 @@ export const getProctoringEventsRaw = async (
.from(examProctoringEvents) .from(examProctoringEvents)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(examProctoringEvents.occurredAt)) .orderBy(desc(examProctoringEvents.occurredAt))
.limit(DEFAULT_LIMIT)
if (rows.length === 0) return [] if (rows.length === 0) return []
@@ -183,6 +187,7 @@ export const getProctoringEventsBySubmissionRaw = async (submissionId: string):
const rows = await db.query.examProctoringEvents.findMany({ const rows = await db.query.examProctoringEvents.findMany({
where: eq(examProctoringEvents.submissionId, submissionId), where: eq(examProctoringEvents.submissionId, submissionId),
orderBy: [desc(examProctoringEvents.occurredAt)], orderBy: [desc(examProctoringEvents.occurredAt)],
limit: DEFAULT_LIMIT,
}) })
return rows.map((row) => ({ return rows.map((row) => ({
@@ -305,7 +310,8 @@ export const getStudentProctoringStatusesRaw = async (examId: string): Promise<S
inArray(examProctoringEvents.studentId, studentIds), inArray(examProctoringEvents.studentId, studentIds),
), ),
) )
.orderBy(desc(examProctoringEvents.occurredAt)), .orderBy(desc(examProctoringEvents.occurredAt))
.limit(DEFAULT_LIMIT),
]) ])
// 4. 按学生聚合 // 4. 按学生聚合

View File

@@ -19,7 +19,7 @@ import {
import { deleteQuestionsBatchAction } from "../actions" import { deleteQuestionsBatchAction } from "../actions"
import { usePermission } from "@/shared/hooks/use-permission" import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
interface BatchOperationsProps { interface BatchOperationsProps {
/** 选中的题目 ID 列表 */ /** 选中的题目 ID 列表 */
@@ -53,16 +53,16 @@ export function BatchOperations({ selectedIds, onClearSelection }: BatchOperatio
const res = await deleteQuestionsBatchAction(undefined, fd) const res = await deleteQuestionsBatchAction(undefined, fd)
if (res.success) { if (res.success) {
const deleted = res.data?.deleted ?? 0 const deleted = res.data?.deleted ?? 0
toast.success(t("batch.deleteSuccess", { count: deleted })) notify.success(t("batch.deleteSuccess", { count: deleted }))
setShowDeleteDialog(false) setShowDeleteDialog(false)
onClearSelection() onClearSelection()
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("batch.deleteFailed")) notify.error(res.message || t("batch.deleteFailed"))
} }
} catch (e) { } catch (e) {
console.error("Failed to batch delete questions", e) console.error("Failed to batch delete questions", e)
toast.error(t("batch.deleteFailed")) notify.error(t("batch.deleteFailed"))
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
} }

View File

@@ -21,7 +21,7 @@ import { SelectField } from "@/shared/components/form-fields/select-field"
import { TextareaField } from "@/shared/components/form-fields/textarea-field" import { TextareaField } from "@/shared/components/form-fields/textarea-field"
import { BaseQuestionSchema } from "../schema" import { BaseQuestionSchema } from "../schema"
import { createQuestionAction, updateQuestionAction } from "../actions" import { createQuestionAction, updateQuestionAction } from "../actions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import type { Question } from "../types" import type { Question } from "../types"
import { import {
parseQuestionContent, parseQuestionContent,
@@ -164,7 +164,7 @@ export function CreateQuestionDialog({
setIsPending(true) setIsPending(true)
try { try {
if (isEdit && !initialData?.id) { if (isEdit && !initialData?.id) {
toast.error(t("error.missingId")) notify.error(t("error.missingId"))
return return
} }
const payload = { const payload = {
@@ -180,18 +180,18 @@ export function CreateQuestionDialog({
? await updateQuestionAction(undefined, fd) ? await updateQuestionAction(undefined, fd)
: await createQuestionAction(undefined, fd) : await createQuestionAction(undefined, fd)
if (res.success) { if (res.success) {
toast.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess")) notify.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess"))
onOpenChange(false) onOpenChange(false)
router.refresh() router.refresh()
if (!isEdit) { if (!isEdit) {
form.reset() form.reset()
} }
} else { } else {
toast.error(res.message || t("error.operationFailed")) notify.error(res.message || t("error.operationFailed"))
} }
} catch (e) { } catch (e) {
console.error("Failed to submit question", e) console.error("Failed to submit question", e)
toast.error(t("error.unexpected")) notify.error(t("error.unexpected"))
} finally { } finally {
setIsPending(false) setIsPending(false)
} }

View File

@@ -17,7 +17,7 @@ import {
import { usePermission } from "@/shared/hooks/use-permission" import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { exportQuestionsAction, importQuestionsAction } from "../actions" import { exportQuestionsAction, importQuestionsAction } from "../actions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
/** /**
* 题目导入/导出组件。 * 题目导入/导出组件。
@@ -60,13 +60,13 @@ export function ImportExportButtons(): React.ReactNode {
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
toast.success(t("importExport.exportSuccess", { count: res.data.length })) notify.success(t("importExport.exportSuccess", { count: res.data.length }))
} else { } else {
toast.error(res.message || t("importExport.exportFailed")) notify.error(res.message || t("importExport.exportFailed"))
} }
} catch (e) { } catch (e) {
console.error("Failed to export questions", e) console.error("Failed to export questions", e)
toast.error(t("importExport.exportFailed")) notify.error(t("importExport.exportFailed"))
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
@@ -83,11 +83,11 @@ export function ImportExportButtons(): React.ReactNode {
setPendingImportData(text) setPendingImportData(text)
setShowImportDialog(true) setShowImportDialog(true)
} else { } else {
toast.error(t("importExport.invalidFile")) notify.error(t("importExport.invalidFile"))
} }
} }
reader.onerror = () => { reader.onerror = () => {
toast.error(t("importExport.readFailed")) notify.error(t("importExport.readFailed"))
} }
reader.readAsText(file) reader.readAsText(file)
// 重置 input 以便重复选择同一文件 // 重置 input 以便重复选择同一文件
@@ -103,16 +103,16 @@ export function ImportExportButtons(): React.ReactNode {
const res = await importQuestionsAction(undefined, fd) const res = await importQuestionsAction(undefined, fd)
if (res.success) { if (res.success) {
const imported = res.data?.imported ?? 0 const imported = res.data?.imported ?? 0
toast.success(t("importExport.importSuccess", { count: imported })) notify.success(t("importExport.importSuccess", { count: imported }))
setShowImportDialog(false) setShowImportDialog(false)
setPendingImportData(null) setPendingImportData(null)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("importExport.importFailed")) notify.error(res.message || t("importExport.importFailed"))
} }
} catch (e) { } catch (e) {
console.error("Failed to import questions", e) console.error("Failed to import questions", e)
toast.error(t("importExport.importFailed")) notify.error(t("importExport.importFailed"))
} finally { } finally {
setIsImporting(false) setIsImporting(false)
} }

View File

@@ -38,7 +38,7 @@ import { CreateQuestionDialog } from "./create-question-dialog"
import { QuestionContentRenderer } from "./question-content-renderer" import { QuestionContentRenderer } from "./question-content-renderer"
import { usePermission } from "@/shared/hooks/use-permission" import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
interface QuestionActionsProps { interface QuestionActionsProps {
question: Question question: Question
@@ -59,10 +59,10 @@ export function QuestionActions({ question }: QuestionActionsProps): React.React
const copyId = (): void => { const copyId = (): void => {
try { try {
navigator.clipboard.writeText(question.id) navigator.clipboard.writeText(question.id)
toast.success(t("actions.copyIdSuccess")) notify.success(t("actions.copyIdSuccess"))
} catch (e) { } catch (e) {
console.error("Failed to copy question ID to clipboard", 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) fd.set("questionId", question.id)
const res = await deleteQuestionAction(undefined, fd) const res = await deleteQuestionAction(undefined, fd)
if (res.success) { if (res.success) {
toast.success(t("actions.deleteSuccess")) notify.success(t("actions.deleteSuccess"))
setShowDeleteDialog(false) setShowDeleteDialog(false)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("actions.deleteFailed")) notify.error(res.message || t("actions.deleteFailed"))
} }
} catch (e) { } catch (e) {
console.error("Failed to delete question", e) console.error("Failed to delete question", e)
toast.error(t("actions.deleteFailed")) notify.error(t("actions.deleteFailed"))
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
} }

View File

@@ -1,7 +1,7 @@
import 'server-only'; import 'server-only';
import { db } from "@/shared/db"; 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 { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
import { cacheFn } from "@/shared/lib/cache"; import { cacheFn } from "@/shared/lib/cache";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
@@ -11,12 +11,25 @@ import {
getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks, getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks,
getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks, getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks,
getTextbooks as getTextbooksFromTextbooks, getTextbooks as getTextbooksFromTextbooks,
getKnowledgePointNamesByIds,
} from "@/modules/textbooks/data-access"; } from "@/modules/textbooks/data-access";
import type { CreateQuestionInput } from "./schema"; import type { CreateQuestionInput } from "./schema";
import type { ChapterOption, KnowledgePointOption, Question, QuestionType, TextbookOption } from "./types"; import type { ChapterOption, KnowledgePointOption, Question, QuestionType, TextbookOption } from "./types";
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0] type Tx = Parameters<Parameters<typeof db.transaction>[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 = { export type UpdateQuestionInput = {
type: QuestionType type: QuestionType
difficulty: number difficulty: number
@@ -36,6 +49,16 @@ export type GetQuestionsParams = {
difficulty?: number; difficulty?: number;
}; };
/**
* 分页查询题目列表,支持按 ID 集合、关键词全文检索、题目类型/难度、
* 知识点/章节/教材级联筛选。
*
* 级联筛选通过 textbooks 模块 data-access 解析知识点 ID 集合,避免直接查询
* textbooks/chapters/knowledgePoints 表。子题parentId 非 null默认不返回
* 除非通过 `ids` 显式指定。
*
* @returns 包含 data 与 metapage/pageSize/total/totalPages的对象
*/
export const getQuestionsRaw = async ({ export const getQuestionsRaw = async ({
q, q,
page = 1, page = 1,
@@ -58,10 +81,13 @@ export const getQuestionsRaw = async ({
} }
if (q && q.trim().length > 0) { if (q && q.trim().length > 0) {
const needle = `%${q.trim().toLowerCase()}%`; // F-02: 使用 FULLTEXT 索引content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描
conditions.push( const booleanQuery = toBooleanModeQuery(q.trim())
sql`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}` if (booleanQuery) {
); conditions.push(
sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`
)
}
} }
if (type) { if (type) {
@@ -201,6 +227,11 @@ export type QuestionsDashboardStats = {
questionCount: number questionCount: number
} }
/**
* 获取题目仪表盘统计(题目总数)。
*
* @returns 包含 questionCount 的统计对象
*/
export const getQuestionsDashboardStatsRaw = async (): Promise<QuestionsDashboardStats> => { export const getQuestionsDashboardStatsRaw = async (): Promise<QuestionsDashboardStats> => {
const [row] = await db.select({ value: count() }).from(questions) const [row] = await db.select({ value: count() }).from(questions)
return { questionCount: Number(row?.value ?? 0) } return { questionCount: Number(row?.value ?? 0) }
@@ -246,6 +277,16 @@ async function insertQuestionWithRelations(
return newQuestionId; return newQuestionId;
} }
/**
* 创建题目(含子题与知识点关联),在单事务内递归插入。
*
* 递归遍历 `input.subQuestions` 创建子题parentId 指向父题 ID。
* 任一子题插入失败则整个事务回滚。
*
* @param input - 题目输入content/type/difficulty/knowledgePointIds/subQuestions
* @param authorId - 创建者用户 ID
* @returns 新创建的根题目 ID
*/
export async function createQuestionWithRelations( export async function createQuestionWithRelations(
input: CreateQuestionInput, input: CreateQuestionInput,
authorId: string 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( export async function updateQuestionById(
id: string, id: string,
input: UpdateQuestionInput, 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<string> = new Set(),
): Promise<string[]> {
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( async function deleteQuestionRecursive(
tx: Tx, tx: Tx,
questionId: string, questionId: string,
visited: Set<string> = new Set(), visited: Set<string> = new Set(),
): Promise<void> { ): Promise<void> {
if (visited.has(questionId)) { // 收集所有后代 ID含自身单条 inArray 批量删除,
// 环检测:避免在异常数据(如循环引用)下无限递归 // 替代原先每层每子节点逐条 SELECT + DELETE 的 N+1 模式。
return // 环检测由 collectDescendantQuestionIds 内部的 visited Set 保证。
} const allIds = await collectDescendantQuestionIds(tx, [questionId], visited)
visited.add(questionId) if (allIds.length === 0) return
await tx.delete(questions).where(inArray(questions.id, allIds))
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 递归删除题目(含所有子题)。
*
* 在事务内1) 查询题目并校验存在性2) 校验权限(非 all 范围只能删除自己创建的题目);
* 3) 通过 BFS 收集所有后代 ID 后单条 `inArray` 批量删除。
*
* @param questionId - 待删除的根题目 ID
* @param canDeleteAll - 是否有全部数据范围权限
* @param authorId - 当前用户 ID用于权限过滤
* @throws 当题目不存在或无权删除时抛出 Error
*/
export async function deleteQuestionByIdRecursive( export async function deleteQuestionByIdRecursive(
questionId: string, questionId: string,
canDeleteAll: boolean, canDeleteAll: boolean,
@@ -369,26 +460,35 @@ export async function deleteQuestionsBatch(
const targetIds = targetRows.map((r) => r.id) const targetIds = targetRows.map((r) => r.id)
if (targetIds.length === 0) return 0 if (targetIds.length === 0) return 0
for (const id of targetIds) { // 一次性收集所有 targetIds 的后代(含自身),单条 inArray 删除,
await deleteQuestionRecursive(tx, id) // 替代原先循环调用 deleteQuestionRecursive 产生的 N×depth 查询。
const visited = new Set<string>()
const allIds = await collectDescendantQuestionIds(tx, targetIds, visited)
if (allIds.length > 0) {
await tx.delete(questions).where(inArray(questions.id, allIds))
} }
return targetIds.length return targetIds.length
}) })
} }
export async function getKnowledgePointOptions(): Promise<KnowledgePointOption[]> { export async function getKnowledgePointOptionsRaw(): Promise<KnowledgePointOption[]> {
// Delegate to textbooks module data-access to avoid direct queries on // Delegate to textbooks module data-access to avoid direct queries on
// textbooks/chapters/knowledgePoints tables (owned by textbooks module). // textbooks/chapters/knowledgePoints tables (owned by textbooks module).
return await getKnowledgePointOptionsFromTextbooks() return await getKnowledgePointOptionsFromTextbooks()
} }
export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, {
tags: ["questions"],
ttl: 600,
keyParts: ["questions", "getKnowledgePointOptions"],
})
/** /**
* 获取教材选项列表(级联筛选第一级)。 * 获取教材选项列表(级联筛选第一级)。
* *
* 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。 * 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。
*/ */
export async function getTextbookOptions(): Promise<TextbookOption[]> { export async function getTextbookOptionsRaw(): Promise<TextbookOption[]> {
const textbooks = await getTextbooksFromTextbooks() const textbooks = await getTextbooksFromTextbooks()
return textbooks.map((tb) => ({ return textbooks.map((tb) => ({
id: tb.id, id: tb.id,
@@ -397,13 +497,18 @@ export async function getTextbookOptions(): Promise<TextbookOption[]> {
grade: tb.grade, grade: tb.grade,
})) }))
} }
export const getTextbookOptions = cacheFn(getTextbookOptionsRaw, {
tags: ["questions"],
ttl: 600,
keyParts: ["questions", "getTextbookOptions"],
})
/** /**
* 获取指定教材下的章节选项列表(级联筛选第二级)。 * 获取指定教材下的章节选项列表(级联筛选第二级)。
* *
* 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。 * 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。
*/ */
export async function getChapterOptions(textbookId: string): Promise<ChapterOption[]> { export async function getChapterOptionsRaw(textbookId: string): Promise<ChapterOption[]> {
const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId) const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId)
const options: ChapterOption[] = [] const options: ChapterOption[] = []
@@ -424,16 +529,26 @@ export async function getChapterOptions(textbookId: string): Promise<ChapterOpti
flatten(chapters) flatten(chapters)
return options return options
} }
export const getChapterOptions = cacheFn(getChapterOptionsRaw, {
tags: ["questions"],
ttl: 600,
keyParts: ["questions", "getChapterOptions"],
})
/** /**
* 获取指定章节下的知识点选项列表(级联筛选第三级)。 * 获取指定章节下的知识点选项列表(级联筛选第三级)。
* *
* 委托 textbooks 模块 data-access 获取知识点列表。 * 委托 textbooks 模块 data-access 获取知识点列表。
*/ */
export async function getKnowledgePointOptionsByChapter(chapterId: string): Promise<{ id: string; name: string }[]> { export async function getKnowledgePointOptionsByChapterRaw(chapterId: string): Promise<{ id: string; name: string }[]> {
const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId)
return kps.map((kp) => ({ id: kp.id, name: kp.name })) 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 // 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))) const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return result if (uniqueIds.length === 0) return result
// 1. 查询 questionId -> knowledgePointId 关联questions 模块自有表)
const rows = await db const rows = await db
.select({ .select({
questionId: questionsToKnowledgePoints.questionId, questionId: questionsToKnowledgePoints.questionId,
knowledgePointId: knowledgePoints.id, knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
knowledgePointName: knowledgePoints.name,
}) })
.from(questionsToKnowledgePoints) .from(questionsToKnowledgePoints)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId))
.where(inArray(questionsToKnowledgePoints.questionId, uniqueIds)) .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) { for (const r of rows) {
const list = result.get(r.questionId) ?? [] const list = result.get(r.questionId) ?? []
list.push({ list.push({
questionId: r.questionId, questionId: r.questionId,
knowledgePointId: r.knowledgePointId, knowledgePointId: r.knowledgePointId,
knowledgePointName: r.knowledgePointName, knowledgePointName: kpNameMap.get(r.knowledgePointId) ?? "",
}) })
result.set(r.questionId, list) result.set(r.questionId, list)
} }
@@ -574,7 +695,7 @@ export interface QuestionExportItem {
* 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。 * 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。
* 遵循权限范围:非 all 范围只能导出自己创建的题目。 * 遵循权限范围:非 all 范围只能导出自己创建的题目。
*/ */
export async function exportQuestions( export async function exportQuestionsRaw(
questionIds?: string[], questionIds?: string[],
canExportAll = true, canExportAll = true,
authorId?: string authorId?: string
@@ -620,6 +741,11 @@ export async function exportQuestions(
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
})) }))
} }
export const exportQuestions = cacheFn(exportQuestionsRaw, {
tags: ["questions"],
ttl: 60,
keyParts: ["questions", "exportQuestions"],
})
/** 导入题目的单条输入 */ /** 导入题目的单条输入 */
export interface QuestionImportItem { export interface QuestionImportItem {
@@ -660,3 +786,42 @@ export async function importQuestions(
return createdIds return createdIds
}) })
} }
// ---------------------------------------------------------------------------
// Cross-module batch interfaces — question count per knowledge point
// ---------------------------------------------------------------------------
/**
* 跨模块接口:按知识点 ID 批量获取关联题目数。
*
* 供 textbooks 模块知识图谱使用,避免直接查询 questionsToKnowledgePoints 表。
*
* @param knowledgePointIds 知识点 ID 列表
* @returns Map<knowledgePointId, questionCount>
*/
export const getQuestionCountByKpIdsRaw = async (
knowledgePointIds: string[]
): Promise<Map<string, number>> => {
const result = new Map<string, number>()
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"],
})

View File

@@ -4,7 +4,7 @@ import { after } from "next/server"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" 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 { logAudit } from "@/shared/lib/audit-logger"
import { logDataChange } from "@/shared/lib/change-logger" import { logDataChange } from "@/shared/lib/change-logger"
import { invalidateFor } from "@/shared/lib/cache" import { invalidateFor } from "@/shared/lib/cache"
@@ -248,8 +248,8 @@ export async function setRolePermissionsAction(
} }
const before = await getRolePermissions(parsed.data.roleId) const before = await getRolePermissions(parsed.data.roleId)
// Schema validates all strings are valid permission values, so the cast is safe. // Schema 已用 refine 校验所有字符串均为合法权限值,再用类型守卫收窄到 Permission[]
const permissions = parsed.data.permissions as Permission[] const permissions = parsed.data.permissions.filter(isPermission)
await setRolePermissions(parsed.data.roleId, permissions) await setRolePermissions(parsed.data.roleId, permissions)
trackPermissionChange({ trackPermissionChange({

View File

@@ -128,6 +128,8 @@ export const PERMISSION_CATALOG: PermissionGroup[] = [
labelKey: "rbac:permissions.group.audit", labelKey: "rbac:permissions.group.audit",
permissions: [ permissions: [
{ key: "AUDIT_LOG_READ", value: Permissions.AUDIT_LOG_READ, labelKey: "rbac:permissions.audit.read", descriptionKey: "rbac:permissions.audit.readDesc" }, { 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" },
], ],
}, },
{ {