From b090a815ae78b320824a6d2dd3f89f4978b28e21 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:18:29 +0800 Subject: [PATCH] feat(modules-edu): update ai, adaptive-practice, announcements, attendance, audit, auth - ai: update chat-panel, child-summary, error-book-analysis, grading-assist, lesson-content-generator, markdown-renderer, question-variant-generator, study-path, usage-dashboard, use-ai-chat-stream, use-position-persistence - adaptive-practice: update practice-result-view, practice-session-view, practice-starter, data-access-analytics, lib/type-guards - announcements: update announcement-card, detail, form, data-access - attendance: update attendance-record-list, attendance-rules-form, attendance-sheet, data-access - audit: update actions, audit-log-export-button, audit-retention-settings, data-access - auth: update login-form, register-form, data-access --- .../components/practice-result-view.tsx | 1 - .../components/practice-session-view.tsx | 14 +- .../components/practice-starter.tsx | 14 +- .../data-access-analytics.ts | 125 ++++++++++-------- .../adaptive-practice/lib/type-guards.ts | 12 +- src/modules/ai/components/ai-chat-panel.tsx | 4 +- .../ai/components/ai-child-summary.tsx | 10 +- .../ai/components/ai-error-book-analysis.tsx | 14 +- .../ai/components/ai-grading-assist.tsx | 12 +- .../ai-lesson-content-generator.tsx | 12 +- .../ai/components/ai-markdown-renderer.tsx | 6 +- .../ai-question-variant-generator.tsx | 16 +-- src/modules/ai/components/ai-study-path.tsx | 10 +- .../ai/components/ai-usage-dashboard.tsx | 6 +- src/modules/ai/hooks/use-ai-chat-stream.ts | 16 ++- .../ai/hooks/use-position-persistence.ts | 9 +- .../components/announcement-card.tsx | 8 +- .../components/announcement-detail.tsx | 26 ++-- .../components/announcement-form.tsx | 10 +- src/modules/announcements/data-access.ts | 6 +- .../components/attendance-record-list.tsx | 8 +- .../components/attendance-rules-form.tsx | 10 +- .../components/attendance-sheet.tsx | 17 +-- src/modules/attendance/data-access.ts | 18 ++- src/modules/audit/actions.ts | 7 +- .../components/audit-log-export-button.tsx | 8 +- .../components/audit-retention-settings.tsx | 12 +- src/modules/audit/data-access.ts | 3 +- src/modules/auth/components/login-form.tsx | 3 +- src/modules/auth/components/register-form.tsx | 17 +-- src/modules/auth/data-access.ts | 39 +++--- 31 files changed, 259 insertions(+), 214 deletions(-) diff --git a/src/modules/adaptive-practice/components/practice-result-view.tsx b/src/modules/adaptive-practice/components/practice-result-view.tsx index 2b5a9b2..abc522d 100644 --- a/src/modules/adaptive-practice/components/practice-result-view.tsx +++ b/src/modules/adaptive-practice/components/practice-result-view.tsx @@ -23,7 +23,6 @@ interface PracticeResultViewProps { export function PracticeResultView({ session }: PracticeResultViewProps): React.ReactNode { const t = useTranslations("practice") - const total = session.totalQuestions const answered = session.answeredQuestions const correct = session.correctCount const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0 diff --git a/src/modules/adaptive-practice/components/practice-session-view.tsx b/src/modules/adaptive-practice/components/practice-session-view.tsx index ee03898..8aa6f8c 100644 --- a/src/modules/adaptive-practice/components/practice-session-view.tsx +++ b/src/modules/adaptive-practice/components/practice-session-view.tsx @@ -4,7 +4,7 @@ import { useState, useTransition, useMemo } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { CheckCircle2, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -128,7 +128,7 @@ export function PracticeSessionView({ return next }) analytics.trackAnswerSubmit(session.id, current.id, data.isCorrect) - toast.success(res.message ?? t("toasts.submitted")) + notify.success(res.message ?? t("toasts.submitted")) // 自动跳到下一题 if (currentIndex < total - 1) { setCurrentIndex(currentIndex + 1) @@ -136,7 +136,7 @@ export function PracticeSessionView({ } else { // 标记提交失败,显示重试按钮 setFailedAnswerIds((prev) => new Set(prev).add(current.id)) - toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed")) + notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed")) } }) } @@ -148,10 +148,10 @@ export function PracticeSessionView({ const res = await service.completeSession(undefined, formData) if (res.success) { analytics.trackSessionComplete(session.id, session.accuracy) - toast.success(res.message ?? t("toasts.completed")) + notify.success(res.message ?? t("toasts.completed")) router.refresh() } else { - toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed")) + notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed")) } }) } @@ -163,7 +163,7 @@ export function PracticeSessionView({ const res = await service.abandonSession(undefined, formData) if (res.success) { analytics.trackSessionAbandon(session.id) - toast.success(res.message ?? t("toasts.abandoned")) + notify.success(res.message ?? t("toasts.abandoned")) // 优先使用注入的回调,否则回退到默认路由 if (onAbandoned) { onAbandoned() @@ -171,7 +171,7 @@ export function PracticeSessionView({ router.push("/student/practice") } } else { - toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed")) + notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed")) } }) } diff --git a/src/modules/adaptive-practice/components/practice-starter.tsx b/src/modules/adaptive-practice/components/practice-starter.tsx index 33f9c03..3de3389 100644 --- a/src/modules/adaptive-practice/components/practice-starter.tsx +++ b/src/modules/adaptive-practice/components/practice-starter.tsx @@ -3,7 +3,7 @@ import { useState, useTransition } from "react" import { useTranslations } from "next-intl" import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -140,10 +140,10 @@ export function PracticeStarter({ const res = await service.createSession(undefined, formData) if (res.success && res.data) { analytics.trackSessionStart(presetMode.type, questionCount) - toast.success(res.message ?? t("toasts.created")) + notify.success(res.message ?? t("toasts.created")) onSessionCreated?.(res.data.sessionId) } else { - toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed")) + notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed")) } }) return @@ -154,7 +154,7 @@ export function PracticeStarter({ if (practiceType === "knowledge_point") { if (selectedKpIds.length === 0) { - toast.error(t("toasts.selectKnowledgePoint")) + notify.error(t("toasts.selectKnowledgePoint")) return } sourceMeta = { @@ -165,7 +165,7 @@ export function PracticeStarter({ // 薄弱章节模式:传入选中的知识点作为薄弱知识点 // 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点 if (selectedKpIds.length === 0) { - toast.error(t("toasts.selectWeakKnowledgePoint")) + notify.error(t("toasts.selectWeakKnowledgePoint")) return } sourceMeta = { @@ -192,10 +192,10 @@ export function PracticeStarter({ const res = await service.createSession(undefined, formData) if (res.success && res.data) { analytics.trackSessionStart(practiceType, questionCount) - toast.success(res.message ?? t("toasts.created")) + notify.success(res.message ?? t("toasts.created")) onSessionCreated?.(res.data.sessionId) } else { - toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed")) + notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed")) } }) } diff --git a/src/modules/adaptive-practice/data-access-analytics.ts b/src/modules/adaptive-practice/data-access-analytics.ts index 837040e..8aa46d3 100644 --- a/src/modules/adaptive-practice/data-access-analytics.ts +++ b/src/modules/adaptive-practice/data-access-analytics.ts @@ -11,6 +11,7 @@ import { getClassNameById, getClassesByGradeId, } from "@/modules/classes/data-access" +import { getActiveStudentIdsByClassIdsBatch } from "@/modules/classes/data-access-teacher" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" // --------------------------------------------------------------------------- @@ -302,9 +303,11 @@ export interface TeacherClassPracticeOverview { * * 用于教师端分析页面顶部展示每个班级的练习情况。 * - * N+1 优化: + * N+1 优化(G2-003): * - 批量获取所有班级名称(1 条 SQL 替代 N 条) - * - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL) + * - 批量获取所有班级的活跃学生 ID(1 条 SQL 替代 N 条) + * - 单条 GROUP BY studentId 聚合所有学生的练习统计(1 条 SQL 替代 N 条), + * 再在应用层按班级归并汇总。 * * @param classIds 教师/年级主任可访问的班级 ID 列表 */ @@ -316,69 +319,79 @@ export const getTeacherClassPracticeOverviewsRaw = async ( // 批量获取所有班级名称(1 条 SQL) const classNameMap = await getClassNamesByIds(classIds) - // 并行获取每个班级的学生 ID 列表 - const studentIdsPerClass = await Promise.all( - classIds.map(async (classId) => ({ - classId, - studentIds: await getActiveStudentIdsByClassId(classId), - })), - ) + // 批量获取所有班级的活跃学生 ID(1 条 SQL 替代 N 条) + const studentIdsByClass = await getActiveStudentIdsByClassIdsBatch(classIds) - // 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数) - const statsPerClass = await Promise.all( - studentIdsPerClass.map(async ({ classId, studentIds }) => { - const totalStudents = studentIds.length - if (totalStudents === 0) { - return { - classId, - totalStudents: 0, - totalSessions: 0, - completedSessions: 0, - totalQuestionsAnswered: 0, - totalCorrect: 0, - activeStudents: 0, - } - } + // 收集所有去重学生 ID + const allStudentIds = new Set() + for (const ids of studentIdsByClass.values()) { + for (const id of ids) allStudentIds.add(id) + } - const rows = await db - .select({ - totalSessions: count(), - completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`), - totalQuestionsAnswered: sql`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`, - totalCorrect: sql`COALESCE(SUM(${practiceSessions.correctCount}), 0)`, - activeStudents: sql`COUNT(DISTINCT ${practiceSessions.studentId})`, - }) - .from(practiceSessions) - .where(inArray(practiceSessions.studentId, studentIds)) + // 单条 GROUP BY studentId 聚合所有学生的练习统计(1 条 SQL 替代 N 条) + const studentStatsMap = new Map() - const row = rows[0] - return { - classId, - totalStudents, - totalSessions: Number(row?.totalSessions ?? 0), - completedSessions: Number(row?.completedSessions ?? 0), - totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0), - totalCorrect: Number(row?.totalCorrect ?? 0), - activeStudents: Number(row?.activeStudents ?? 0), - } - }), - ) + if (allStudentIds.size > 0) { + const statsRows = await db + .select({ + studentId: practiceSessions.studentId, + totalSessions: count(), + completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`), + totalQuestionsAnswered: sql`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`, + totalCorrect: sql`COALESCE(SUM(${practiceSessions.correctCount}), 0)`, + }) + .from(practiceSessions) + .where(inArray(practiceSessions.studentId, Array.from(allStudentIds))) + .groupBy(practiceSessions.studentId) + + for (const r of statsRows) { + studentStatsMap.set(r.studentId, { + totalSessions: Number(r.totalSessions), + completedSessions: Number(r.completedSessions), + totalQuestionsAnswered: Number(r.totalQuestionsAnswered), + totalCorrect: Number(r.totalCorrect), + }) + } + } + + // 在应用层按班级归并汇总(替代原先 N 条 SQL) + return classIds.map((classId) => { + const studentIds = studentIdsByClass.get(classId) ?? [] + const totalStudents = studentIds.length + + let totalSessions = 0 + let completedSessions = 0 + let totalQuestionsAnswered = 0 + let totalCorrect = 0 + let activeStudents = 0 + + for (const studentId of studentIds) { + const s = studentStatsMap.get(studentId) + if (!s) continue + // 有练习记录的学生计为活跃 + if (s.totalSessions > 0) activeStudents += 1 + totalSessions += s.totalSessions + completedSessions += s.completedSessions + totalQuestionsAnswered += s.totalQuestionsAnswered + totalCorrect += s.totalCorrect + } - return statsPerClass.map((s) => { - const totalQuestionsAnswered = s.totalQuestionsAnswered - const totalCorrect = s.totalCorrect - const activeStudents = s.activeStudents return { - classId: s.classId, - className: classNameMap.get(s.classId) ?? "", - totalSessions: s.totalSessions, - completedSessions: s.completedSessions, + classId, + className: classNameMap.get(classId) ?? "", + totalSessions, + completedSessions, totalQuestionsAnswered, totalCorrect, averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, activeStudents, - totalStudents: s.totalStudents, - participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0, + totalStudents, + participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0, } }) } diff --git a/src/modules/adaptive-practice/lib/type-guards.ts b/src/modules/adaptive-practice/lib/type-guards.ts index c56f353..8042f71 100644 --- a/src/modules/adaptive-practice/lib/type-guards.ts +++ b/src/modules/adaptive-practice/lib/type-guards.ts @@ -19,35 +19,35 @@ import type { PracticeType, } from "../types" -const PRACTICE_TYPES = new Set([ +const PRACTICE_TYPES = new Set([ "error_variant", "knowledge_point", "weak_chapter", "ai_recommended", ]) -const PRACTICE_STATUSES = new Set([ +const PRACTICE_STATUSES = new Set([ "in_progress", "completed", "abandoned", ]) -const PRACTICE_ANSWER_STATUSES = new Set([ +const PRACTICE_ANSWER_STATUSES = new Set([ "pending", "answered", "skipped", ]) export function isPracticeType(value: unknown): value is PracticeType { - return typeof value === "string" && PRACTICE_TYPES.has(value as PracticeType) + return typeof value === "string" && PRACTICE_TYPES.has(value) } export function isPracticeStatus(value: unknown): value is PracticeStatus { - return typeof value === "string" && PRACTICE_STATUSES.has(value as PracticeStatus) + return typeof value === "string" && PRACTICE_STATUSES.has(value) } export function isPracticeAnswerStatus(value: unknown): value is PracticeAnswerStatus { - return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value as PracticeAnswerStatus) + return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value) } /** diff --git a/src/modules/ai/components/ai-chat-panel.tsx b/src/modules/ai/components/ai-chat-panel.tsx index 31e87f8..4ec7374 100644 --- a/src/modules/ai/components/ai-chat-panel.tsx +++ b/src/modules/ai/components/ai-chat-panel.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react" import { useTranslations } from "next-intl" import { Bot, Trash2 } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -107,7 +107,7 @@ export function AiChatPanel({ const handleClear = (): void => { if (window.confirm(t("chat.clearConfirm"))) { clear() - toast.success(t("chat.clear")) + notify.success(t("chat.clear")) } } diff --git a/src/modules/ai/components/ai-child-summary.tsx b/src/modules/ai/components/ai-child-summary.tsx index b008389..42872f9 100644 --- a/src/modules/ai/components/ai-child-summary.tsx +++ b/src/modules/ai/components/ai-child-summary.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useTranslations } from "next-intl" import { Sparkles, TrendingUp, Lightbulb, CheckCircle, AlertCircle } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" @@ -49,7 +49,7 @@ export function AiChildSummary({ const handleGenerate = async (): Promise => { if (!aiClient.generateChildSummary) { - toast.error(t("parent.error")) + notify.error(t("parent.error")) return } setLoading(true) @@ -64,12 +64,12 @@ export function AiChildSummary({ }) if (result.success && result.data) { setSummary(result.data) - toast.success(t("parent.summary")) + notify.success(t("parent.summary")) } else { - toast.error(result.message ?? t("parent.error")) + notify.error(result.message ?? t("parent.error")) } } catch { - toast.error(t("parent.error")) + notify.error(t("parent.error")) } finally { setLoading(false) } diff --git a/src/modules/ai/components/ai-error-book-analysis.tsx b/src/modules/ai/components/ai-error-book-analysis.tsx index cd488ec..490e306 100644 --- a/src/modules/ai/components/ai-error-book-analysis.tsx +++ b/src/modules/ai/components/ai-error-book-analysis.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useTranslations } from "next-intl" import { Sparkles, Lightbulb, BookOpen, TrendingDown } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" @@ -70,12 +70,12 @@ export function AiErrorBookAnalysis({ }) if (result.success && result.data) { setSimilarQuestions(result.data) - toast.success(t("suggestion.loaded")) + notify.success(t("suggestion.loaded")) } else { - toast.error(result.message ?? t("suggestion.error")) + notify.error(result.message ?? t("suggestion.error")) } } catch { - toast.error(t("suggestion.error")) + notify.error(t("suggestion.error")) } finally { setSimilarLoading(false) } @@ -92,12 +92,12 @@ export function AiErrorBookAnalysis({ }) if (result.success && result.data) { setWeaknessResult(result.data) - toast.success(t("errorBook.weaknessAnalysis")) + notify.success(t("errorBook.weaknessAnalysis")) } else { - toast.error(result.message ?? t("error.analysisFailed")) + notify.error(result.message ?? t("error.analysisFailed")) } } catch { - toast.error(t("error.analysisFailed")) + notify.error(t("error.analysisFailed")) } finally { setWeaknessLoading(false) } diff --git a/src/modules/ai/components/ai-grading-assist.tsx b/src/modules/ai/components/ai-grading-assist.tsx index 3bd0617..ab674b3 100644 --- a/src/modules/ai/components/ai-grading-assist.tsx +++ b/src/modules/ai/components/ai-grading-assist.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useTranslations } from "next-intl" import { Sparkles, Check } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" @@ -73,12 +73,12 @@ export function AiGradingAssist({ }) if (result.success && result.data) { setSuggestion(result.data) - toast.success(t("grading.title")) + notify.success(t("grading.title")) } else { - toast.error(result.message ?? t("grading.error")) + notify.error(result.message ?? t("grading.error")) } } catch { - toast.error(t("grading.error")) + notify.error(t("grading.error")) } finally { setLoading(false) } @@ -134,7 +134,7 @@ export function AiGradingAssist({ size="sm" onClick={() => { onApplyScore(suggestion.suggestedScore) - toast.success(t("grading.suggestedScore")) + notify.success(t("grading.suggestedScore")) }} > @@ -148,7 +148,7 @@ export function AiGradingAssist({ size="sm" onClick={() => { onApplyFeedback(suggestion.feedback) - toast.success(t("grading.feedback")) + notify.success(t("grading.feedback")) }} > diff --git a/src/modules/ai/components/ai-lesson-content-generator.tsx b/src/modules/ai/components/ai-lesson-content-generator.tsx index b109bf6..69d001e 100644 --- a/src/modules/ai/components/ai-lesson-content-generator.tsx +++ b/src/modules/ai/components/ai-lesson-content-generator.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useTranslations } from "next-intl" import { Sparkles, BookOpen, Lightbulb, HelpCircle, FileText } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" @@ -56,7 +56,7 @@ export function AiLessonContentGenerator({ const handleGenerate = async (): Promise => { if (!topic.trim()) { - toast.error(t("lessonPrep.error")) + notify.error(t("lessonPrep.error")) return } setLoading(true) @@ -72,12 +72,12 @@ export function AiLessonContentGenerator({ }) if (response.success && response.data) { setResult(response.data) - toast.success(t("lessonPrep.generateContent")) + notify.success(t("lessonPrep.generateContent")) } else { - toast.error(response.message ?? t("lessonPrep.error")) + notify.error(response.message ?? t("lessonPrep.error")) } } catch { - toast.error(t("lessonPrep.error")) + notify.error(t("lessonPrep.error")) } finally { setLoading(false) } @@ -165,7 +165,7 @@ export function AiLessonContentGenerator({ size="sm" onClick={() => { onInsertContent(result) - toast.success(t("lessonPrep.insertContent")) + notify.success(t("lessonPrep.insertContent")) }} > {t("lessonPrep.insertContent")} diff --git a/src/modules/ai/components/ai-markdown-renderer.tsx b/src/modules/ai/components/ai-markdown-renderer.tsx index 597a4ba..df86333 100644 --- a/src/modules/ai/components/ai-markdown-renderer.tsx +++ b/src/modules/ai/components/ai-markdown-renderer.tsx @@ -5,7 +5,7 @@ import ReactMarkdown from "react-markdown" import remarkGfm from "remark-gfm" import { Copy, Check } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { cn } from "@/shared/lib/utils" @@ -73,10 +73,10 @@ function AiMarkdownRendererImpl({ try { await navigator.clipboard.writeText(content) setCopied(true) - toast.success(t("chat.copied")) + notify.success(t("chat.copied")) setTimeout(() => setCopied(false), 2000) } catch { - toast.error(t("error.chatFailed")) + notify.error(t("error.chatFailed")) } }, [content, t]) diff --git a/src/modules/ai/components/ai-question-variant-generator.tsx b/src/modules/ai/components/ai-question-variant-generator.tsx index 2f11a02..bbf1249 100644 --- a/src/modules/ai/components/ai-question-variant-generator.tsx +++ b/src/modules/ai/components/ai-question-variant-generator.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useTranslations } from "next-intl" import { Sparkles, RefreshCw, Plus } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" @@ -22,10 +22,10 @@ import type { QuestionVariantResult } from "@/modules/ai/types" type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format" -const VARIANT_TYPES: readonly VariantType[] = ["same_knowledge_point", "different_difficulty", "different_format"] +const VARIANT_TYPES: readonly string[] = ["same_knowledge_point", "different_difficulty", "different_format"] const isVariantType = (value: string): value is VariantType => { - return VARIANT_TYPES.includes(value as VariantType) + return VARIANT_TYPES.includes(value) } type AiQuestionVariantGeneratorProps = { @@ -66,7 +66,7 @@ export function AiQuestionVariantGenerator({ const handleGenerate = async (): Promise => { if (!originalQuestion.text.trim()) { - toast.error(t("error.invalidInput")) + notify.error(t("error.invalidInput")) return } setLoading(true) @@ -78,12 +78,12 @@ export function AiQuestionVariantGenerator({ }) if (result.success && result.data) { setVariant(result.data) - toast.success(t("exam.generate")) + notify.success(t("exam.generate")) } else { - toast.error(result.message ?? t("error.variantFailed")) + notify.error(result.message ?? t("error.variantFailed")) } } catch { - toast.error(t("error.variantFailed")) + notify.error(t("error.variantFailed")) } finally { setLoading(false) } @@ -192,7 +192,7 @@ export function AiQuestionVariantGenerator({ size="sm" onClick={() => { onAddVariant(variant) - toast.success(t("exam.generate")) + notify.success(t("exam.generate")) }} > diff --git a/src/modules/ai/components/ai-study-path.tsx b/src/modules/ai/components/ai-study-path.tsx index 4379930..5538056 100644 --- a/src/modules/ai/components/ai-study-path.tsx +++ b/src/modules/ai/components/ai-study-path.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useTranslations } from "next-intl" import { Sparkles, CheckCircle, Clock, AlertCircle, Target } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" @@ -51,7 +51,7 @@ export function AiStudyPath({ const handleGenerate = async (): Promise => { if (!aiClient.recommendStudyPath) { - toast.error(t("studyPath.error")) + notify.error(t("studyPath.error")) return } setLoading(true) @@ -65,12 +65,12 @@ export function AiStudyPath({ }) if (result.success && result.data) { setPath(result.data) - toast.success(t("studyPath.title")) + notify.success(t("studyPath.title")) } else { - toast.error(result.message ?? t("studyPath.error")) + notify.error(result.message ?? t("studyPath.error")) } } catch { - toast.error(t("studyPath.error")) + notify.error(t("studyPath.error")) } finally { setLoading(false) } diff --git a/src/modules/ai/components/ai-usage-dashboard.tsx b/src/modules/ai/components/ai-usage-dashboard.tsx index 62985ce..e0b78ff 100644 --- a/src/modules/ai/components/ai-usage-dashboard.tsx +++ b/src/modules/ai/components/ai-usage-dashboard.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react" import { useTranslations } from "next-intl" import { Activity, Users, AlertTriangle, Clock } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" @@ -41,10 +41,10 @@ export function AiUsageDashboard(): React.ReactNode { if (result.success && result.data) { setStats(result.data) } else { - toast.error(result.message ?? t("error.statsFailed")) + notify.error(result.message ?? t("error.statsFailed")) } } catch { - toast.error(t("error.statsFailed")) + notify.error(t("error.statsFailed")) } finally { setLoading(false) } diff --git a/src/modules/ai/hooks/use-ai-chat-stream.ts b/src/modules/ai/hooks/use-ai-chat-stream.ts index 2c61684..dbab072 100644 --- a/src/modules/ai/hooks/use-ai-chat-stream.ts +++ b/src/modules/ai/hooks/use-ai-chat-stream.ts @@ -29,15 +29,25 @@ type UseAiChatStreamReturn = { clear: () => void } +function isAiChatMessage(value: unknown): value is AiChatMessage { + if (value === null || typeof value !== "object") return false + return ( + "role" in value && + (value.role === "system" || value.role === "user" || value.role === "assistant") && + "content" in value && + typeof value.content === "string" + ) +} + function loadHistory(): AiChatMessage[] { if (typeof window === "undefined") return [] try { const raw = localStorage.getItem(HISTORY_STORAGE_KEY) if (!raw) return [] - const parsed = JSON.parse(raw) as AiChatMessage[] + const parsed: unknown = JSON.parse(raw) if (!Array.isArray(parsed)) return [] - // 过滤掉空的 assistant 消息 - return parsed.filter((m) => m && m.role && typeof m.content === "string") + // 从 unknown 转换:localStorage 数据不可信,用类型守卫过滤不合法的项 + return parsed.filter(isAiChatMessage) } catch { return [] } diff --git a/src/modules/ai/hooks/use-position-persistence.ts b/src/modules/ai/hooks/use-position-persistence.ts index 8c7d147..9dea4f7 100644 --- a/src/modules/ai/hooks/use-position-persistence.ts +++ b/src/modules/ai/hooks/use-position-persistence.ts @@ -32,8 +32,13 @@ export function loadPosition(): Position { try { const raw = localStorage.getItem(STORAGE_KEY) if (raw) { - const parsed = JSON.parse(raw) as Partial - if (typeof parsed.x === "number" && typeof parsed.y === "number") { + const parsed: unknown = JSON.parse(raw) + // 从 unknown 转换:localStorage 数据不可信,做字段类型校验 + if ( + typeof parsed === "object" && parsed !== null && + "x" in parsed && typeof parsed.x === "number" && + "y" in parsed && typeof parsed.y === "number" + ) { return clampPosition({ x: parsed.x, y: parsed.y }) } } diff --git a/src/modules/announcements/components/announcement-card.tsx b/src/modules/announcements/components/announcement-card.tsx index ea9a61f..2c3b9a9 100644 --- a/src/modules/announcements/components/announcement-card.tsx +++ b/src/modules/announcements/components/announcement-card.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 { Pin } from "lucide-react" @@ -56,17 +56,17 @@ export function AnnouncementCard({ try { const res = await service.togglePin(announcement.id) if (res.success) { - toast.success(t("messages.pinToggled")) + notify.success(t("messages.pinToggled")) router.refresh() } else { // 回滚 setIsPinned(prevPinned) - toast.error(res.message) + notify.error(res.message) } } catch { // 回滚 setIsPinned(prevPinned) - toast.error(t("messages.publishFailed")) + notify.error(t("messages.publishFailed")) } finally { setIsToggling(false) } diff --git a/src/modules/announcements/components/announcement-detail.tsx b/src/modules/announcements/components/announcement-detail.tsx index 41815e8..1fb0e76 100644 --- a/src/modules/announcements/components/announcement-detail.tsx +++ b/src/modules/announcements/components/announcement-detail.tsx @@ -3,7 +3,7 @@ import { useEffect, 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 { Archive, @@ -72,13 +72,13 @@ export function AnnouncementDetail({ try { const res = await service.publish(announcement.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.refresh() } else { - toast.error(res.message || t("messages.publishFailed")) + notify.error(res.message || t("messages.publishFailed")) } } catch { - toast.error(t("messages.publishFailed")) + notify.error(t("messages.publishFailed")) } finally { setIsWorking(false) } @@ -89,13 +89,13 @@ export function AnnouncementDetail({ try { const res = await service.archive(announcement.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.refresh() } else { - toast.error(res.message || t("messages.archiveFailed")) + notify.error(res.message || t("messages.archiveFailed")) } } catch { - toast.error(t("messages.archiveFailed")) + notify.error(t("messages.archiveFailed")) } finally { setIsWorking(false) } @@ -106,14 +106,14 @@ export function AnnouncementDetail({ try { const res = await service.delete(announcement.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.push("/admin/announcements") 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) @@ -128,17 +128,17 @@ export function AnnouncementDetail({ try { const res = await service.togglePin(announcement.id) if (res.success) { - toast.success(t("messages.pinToggled")) + notify.success(t("messages.pinToggled")) router.refresh() } else { // 回滚 setIsPinned(prevPinned) - toast.error(res.message) + notify.error(res.message) } } catch { // 回滚 setIsPinned(prevPinned) - toast.error(t("messages.publishFailed")) + notify.error(t("messages.publishFailed")) } finally { setIsTogglingPin(false) } diff --git a/src/modules/announcements/components/announcement-form.tsx b/src/modules/announcements/components/announcement-form.tsx index ab21a1d..b2fdd97 100644 --- a/src/modules/announcements/components/announcement-form.tsx +++ b/src/modules/announcements/components/announcement-form.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 { Button } from "@/shared/components/ui/button" @@ -110,22 +110,22 @@ export function AnnouncementForm({ : null if (!res) { - toast.error(t("messages.invalidState")) + notify.error(t("messages.invalidState")) return } if (res.success) { - toast.success(res.message) + notify.success(res.message) handleSuccess() } else { // 展示字段级错误(来自 Zod superRefine 校验) if (res.errors) { setFieldErrors(res.errors) } - toast.error(res.message || t("messages.updateFailed")) + notify.error(res.message || t("messages.updateFailed")) } } catch { - toast.error(t("messages.updateFailed")) + notify.error(t("messages.updateFailed")) } finally { setIsWorking(false) } diff --git a/src/modules/announcements/data-access.ts b/src/modules/announcements/data-access.ts index 2e0b946..a9666a8 100644 --- a/src/modules/announcements/data-access.ts +++ b/src/modules/announcements/data-access.ts @@ -7,6 +7,7 @@ import { db } from "@/shared/db" import { announcements, announcementReads, users } from "@/shared/db/schema" import type { DataScope } from "@/shared/types/permissions" import { cacheFn } from "@/shared/lib/cache" +import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils" import type { Announcement, AnnouncementInsertData, @@ -22,11 +23,6 @@ type AnnouncementRow = typeof announcements.$inferSelect & { authorName: string | null } -const toIso = (d: Date | null | undefined): string | null => - d ? d.toISOString() : null - -const toIsoRequired = (d: Date): string => d.toISOString() - const mapRow = (row: AnnouncementRow): Announcement => ({ id: row.id, title: row.title, diff --git a/src/modules/attendance/components/attendance-record-list.tsx b/src/modules/attendance/components/attendance-record-list.tsx index 1463fdd..b252b76 100644 --- a/src/modules/attendance/components/attendance-record-list.tsx +++ b/src/modules/attendance/components/attendance-record-list.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { Trash2, Inbox } from "lucide-react" @@ -60,14 +60,14 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[ try { const result = await deleteAttendanceAction(deleteId) if (result.success) { - toast.success(result.message || t("sheet.deleted")) + notify.success(result.message || t("sheet.deleted")) setDeleteId(null) router.refresh() } else { - toast.error(resolveActionError(result, t, t("errors.unexpected"))) + notify.error(resolveActionError(result, t, t("errors.unexpected"))) } } catch { - toast.error(t("errors.unexpected")) + notify.error(t("errors.unexpected")) } finally { setIsDeleting(false) } diff --git a/src/modules/attendance/components/attendance-rules-form.tsx b/src/modules/attendance/components/attendance-rules-form.tsx index 393ffd2..347bef5 100644 --- a/src/modules/attendance/components/attendance-rules-form.tsx +++ b/src/modules/attendance/components/attendance-rules-form.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { useFormStatus } from "react-dom" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -71,7 +71,7 @@ export function AttendanceRulesForm({ const handleSubmit = async (formData: FormData) => { if (!classId) { - toast.error(t("sheet.selectClass")) + notify.error(t("sheet.selectClass")) return } formData.set("classId", classId) @@ -84,13 +84,13 @@ export function AttendanceRulesForm({ try { const result = await saveAttendanceRulesAction(null, formData) if (result.success) { - toast.success(result.message || t("rules.saved")) + notify.success(result.message || t("rules.saved")) router.refresh() } else { - toast.error(resolveActionError(result, t, t("errors.unexpected"))) + notify.error(resolveActionError(result, t, t("errors.unexpected"))) } } catch { - toast.error(t("errors.unexpected")) + notify.error(t("errors.unexpected")) } } diff --git a/src/modules/attendance/components/attendance-sheet.tsx b/src/modules/attendance/components/attendance-sheet.tsx index 52625b7..ad0d7c8 100644 --- a/src/modules/attendance/components/attendance-sheet.tsx +++ b/src/modules/attendance/components/attendance-sheet.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo, useOptimistic } from "react" import { useFormStatus } from "react-dom" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react" @@ -48,6 +48,7 @@ import { type AttendanceStatus, } from "../constants" import type { AttendancePeriod } from "../types" +import { isAttendancePeriod } from "../lib/type-guards" type Option = { id: string; name: string } type Student = { id: string; name: string; email: string } @@ -153,7 +154,7 @@ export function AttendanceSheet({ for (const s of students) all[s.id] = "present" setStatuses(all) setReasons({}) - toast.success(t("actions.markAllPresent")) + notify.success(t("actions.markAllPresent")) }, [students, t]) const handleClassChange = (newClassId: string) => { @@ -236,7 +237,7 @@ export function AttendanceSheet({ const handleSubmit = async (formData: FormData) => { if (!classId || !date) { - toast.error(t("errors.invalidForm")) + notify.error(t("errors.invalidForm")) return } @@ -258,7 +259,7 @@ export function AttendanceSheet({ }) if (records.length === 0) { - toast.error(t("sheet.noStudents")) + notify.error(t("sheet.noStudents")) return } @@ -269,14 +270,14 @@ export function AttendanceSheet({ try { const result = await batchRecordAttendanceAction(null, formData) if (result.success) { - toast.success(result.message || t("sheet.saved")) + notify.success(result.message || t("sheet.saved")) router.push("/teacher/attendance") router.refresh() } else { - toast.error(resolveActionError(result, t, t("errors.unexpected"))) + notify.error(resolveActionError(result, t, t("errors.unexpected"))) } } catch { - toast.error(t("errors.unexpected")) + notify.error(t("errors.unexpected")) } } @@ -334,7 +335,7 @@ export function AttendanceSheet({ {/* L-6:节次选择器,允许按节次记录考勤 */}
- setPeriod(isAttendancePeriod(v) ? v : "full_day")}> diff --git a/src/modules/attendance/data-access.ts b/src/modules/attendance/data-access.ts index 1b89056..208c322 100644 --- a/src/modules/attendance/data-access.ts +++ b/src/modules/attendance/data-access.ts @@ -59,7 +59,9 @@ const serializeDate = (d: Date | string | null): string => function buildPeriodFilter(period?: AttendancePeriod): SQL | null { if (!period) return null if (period === "full_day") { - return or(isNull(attendanceRecords.period), eq(attendanceRecords.period, "full_day")) as SQL + // Drizzle ORM 的 or() 返回 SQL | undefined,用 ?? null 统一为 SQL | null + const result = or(isNull(attendanceRecords.period), eq(attendanceRecords.period, "full_day")) + return result ?? null } return eq(attendanceRecords.period, period) } @@ -310,7 +312,17 @@ export async function getAttendanceRulesRaw(classId?: string): Promise 0 ? and(...conditions) : undefined) .orderBy(desc(attendanceRules.createdAt)) @@ -336,7 +348,7 @@ export const getAttendanceRules = cacheFn(getAttendanceRulesRaw, { export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise { const [existing] = await db - .select() + .select({ id: attendanceRules.id }) .from(attendanceRules) .where(eq(attendanceRules.classId, data.classId)) .limit(1) diff --git a/src/modules/audit/actions.ts b/src/modules/audit/actions.ts index c1fb1ff..83a9ddc 100644 --- a/src/modules/audit/actions.ts +++ b/src/modules/audit/actions.ts @@ -164,7 +164,8 @@ export async function saveAuditRetentionConfigAction( config: AuditRetentionConfig, ): Promise> { try { - await requirePermission(Permissions.AUDIT_LOG_READ) + // 审计 v1 P0 修复(G4-004):保留策略是写操作,改用专用写权限点 + await requirePermission(Permissions.AUDIT_RETENTION_MANAGE) const session = await getSession() await saveAuditRetentionConfig(config, session?.user?.id) @@ -194,7 +195,9 @@ export async function purgeAuditLogsAction( loginLogRetentionDays?: number, ): Promise> { try { - await requirePermission(Permissions.AUDIT_LOG_READ) + // 审计 v1 P0 修复(G4-003):purge 是破坏性操作(物理删除审计日志), + // 读权限不应授予删除能力。改用专用 PURGE 权限点(仅 admin)。 + await requirePermission(Permissions.AUDIT_LOG_PURGE) const session = await getSession() // audit-P2-5: 未显式传入时读取配置(含 loginLogRetentionDays) diff --git a/src/modules/audit/components/audit-log-export-button.tsx b/src/modules/audit/components/audit-log-export-button.tsx index 7a467d1..596dcc0 100644 --- a/src/modules/audit/components/audit-log-export-button.tsx +++ b/src/modules/audit/components/audit-log-export-button.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { Download, Loader2 } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { downloadBlob } from "@/shared/lib/download" @@ -45,7 +45,7 @@ export function AuditLogExportButton({ if (!res.ok) { const body = await res.json().catch(() => null) - toast.error(body?.message || t("export.failed")) + notify.error(body?.message || t("export.failed")) return } @@ -56,9 +56,9 @@ export function AuditLogExportButton({ const blob = await res.blob() downloadBlob(blob, filename) analytics.trackExport(exportType, 0) - toast.success(t("export.success")) + notify.success(t("export.success")) } catch { - toast.error(t("export.failed")) + notify.error(t("export.failed")) } finally { setIsPending(false) } diff --git a/src/modules/audit/components/audit-retention-settings.tsx b/src/modules/audit/components/audit-retention-settings.tsx index 0d02d48..0f58dfb 100644 --- a/src/modules/audit/components/audit-retention-settings.tsx +++ b/src/modules/audit/components/audit-retention-settings.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, type ReactNode } from "react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Save, Trash2, Loader2 } from "lucide-react" import { @@ -41,7 +41,7 @@ export function AuditRetentionSettings(): ReactNode { if (cancelled) return setConfig(data) } catch { - if (!cancelled) toast.error(t("saveFailed")) + if (!cancelled) notify.error(t("saveFailed")) } finally { if (!cancelled) setIsLoading(false) } @@ -58,9 +58,9 @@ export function AuditRetentionSettings(): ReactNode { try { await service.saveAuditRetentionConfig(config) analytics.trackRetentionConfigChange(config) - toast.success(t("saveSuccess")) + notify.success(t("saveSuccess")) } catch { - toast.error(t("saveFailed")) + notify.error(t("saveFailed")) } finally { setIsSaving(false) } @@ -78,7 +78,7 @@ export function AuditRetentionSettings(): ReactNode { config.loginLogRetentionDays, ) analytics.trackPurge(result) - toast.success( + notify.success( t("purgeSuccess", { auditLogsDeleted: result.auditLogsDeleted, loginLogsDeleted: result.loginLogsDeleted, @@ -86,7 +86,7 @@ export function AuditRetentionSettings(): ReactNode { }), ) } catch { - toast.error(t("purgeFailed")) + notify.error(t("purgeFailed")) } finally { setIsPurging(false) } diff --git a/src/modules/audit/data-access.ts b/src/modules/audit/data-access.ts index caffb98..32aa659 100644 --- a/src/modules/audit/data-access.ts +++ b/src/modules/audit/data-access.ts @@ -4,6 +4,7 @@ import { and, asc, desc, eq, gte, lte, count, like, type SQL, sql } from "drizzl import { db } from "@/shared/db" import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" import { createModuleLogger } from "@/shared/lib/logger" import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema" import type { @@ -22,8 +23,6 @@ import type { const log = createModuleLogger("audit") -const toIso = (d: Date): string => d.toISOString() - const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 diff --git a/src/modules/auth/components/login-form.tsx b/src/modules/auth/components/login-form.tsx index 4b8f81a..1e91e4e 100644 --- a/src/modules/auth/components/login-form.tsx +++ b/src/modules/auth/components/login-form.tsx @@ -47,7 +47,8 @@ export function LoginForm({ className, ...props }: LoginFormProps) { event.preventDefault() setError("") - const form = event.currentTarget as HTMLFormElement + if (!(event.currentTarget instanceof HTMLFormElement)) return + const form = event.currentTarget const formData = new FormData(form) const email = String(formData.get("email") ?? "").trim() const password = String(formData.get("password") ?? "") diff --git a/src/modules/auth/components/register-form.tsx b/src/modules/auth/components/register-form.tsx index b4e3419..283be08 100644 --- a/src/modules/auth/components/register-form.tsx +++ b/src/modules/auth/components/register-form.tsx @@ -4,7 +4,7 @@ import * as React from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" @@ -56,33 +56,34 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo event.preventDefault() if (!agreedTerms) { - toast.error(t("toast.needAgreeTerms")) + notify.error(t("toast.needAgreeTerms")) return } if (isMinor && !agreedGuardian) { - toast.error(t("toast.needGuardianConsent")) + notify.error(t("toast.needGuardianConsent")) return } if (isMinor && !guardianRelation) { - toast.error(t("toast.needGuardianRelation")) + notify.error(t("toast.needGuardianRelation")) return } setIsLoading(true) try { - const form = event.currentTarget as HTMLFormElement + if (!(event.currentTarget instanceof HTMLFormElement)) return + const form = event.currentTarget const formData = new FormData(form) const res = await registerAction(formData) if (res.success) { - toast.success(res.message || t("toast.createSuccess")) + notify.success(res.message || t("toast.createSuccess")) router.push("/login") router.refresh() } else { - toast.error(res.message || t("toast.createFailed")) + notify.error(res.message || t("toast.createFailed")) } } catch { - toast.error(t("toast.createFailed")) + notify.error(t("toast.createFailed")) } finally { setIsLoading(false) } diff --git a/src/modules/auth/data-access.ts b/src/modules/auth/data-access.ts index 3d63323..b90699e 100644 --- a/src/modules/auth/data-access.ts +++ b/src/modules/auth/data-access.ts @@ -51,22 +51,9 @@ export async function createUser( const normalizedEmail = input.email.trim().toLowerCase() const hashedPassword = normalizeBcryptHash(await hash(input.password, BCRYPT_COST)) - const userId = createId() - await db.insert(users).values({ - id: userId, - name: input.name.length ? input.name : null, - email: normalizedEmail, - password: hashedPassword, - birthDate: input.birthDate ? new Date(input.birthDate) : null, - age: input.age ?? null, - guardianName: input.guardianName || null, - guardianPhone: input.guardianPhone || null, - guardianRelation: input.guardianRelation || null, - consentAcceptedAt: new Date(), - }) - // 查找默认角色(不自动创建) // audit-P2-3: 若 input.role 存在(邀请码注册),使用邀请码中的 role;否则使用 DEFAULT_REGISTER_ROLE + // F-09: 角色查找前置,失败时立即抛错,避免产生孤儿用户记录 const roleName = input.role ?? DEFAULT_REGISTER_ROLE const roleRow = await db.query.roles.findFirst({ where: eq(roles.name, roleName), @@ -75,9 +62,27 @@ export async function createUser( if (!roleRow?.id) { throw new Error("DEFAULT_ROLE_NOT_FOUND") } - await db.insert(usersToRoles).values({ - userId, - roleId: roleRow.id, + + // F-09: 两次 INSERT 必须在同一事务内,保证 users 与 users_to_roles 原子写入 + const userId = createId() + await db.transaction(async (tx) => { + await tx.insert(users).values({ + id: userId, + name: input.name.length ? input.name : null, + email: normalizedEmail, + password: hashedPassword, + birthDate: input.birthDate ? new Date(input.birthDate) : null, + age: input.age ?? null, + guardianName: input.guardianName || null, + guardianPhone: input.guardianPhone || null, + guardianRelation: input.guardianRelation || null, + consentAcceptedAt: new Date(), + }) + + await tx.insert(usersToRoles).values({ + userId, + roleId: roleRow.id, + }) }) return { userId }