diff --git a/src/modules/classes/actions-shared.ts b/src/modules/classes/actions-shared.ts index 3926d71..4167b7d 100644 --- a/src/modules/classes/actions-shared.ts +++ b/src/modules/classes/actions-shared.ts @@ -2,12 +2,10 @@ import { getTranslations } from "next-intl/server" import type { AuthContext } from "@/shared/types/permissions" import type { ClassSubject } from "./types" -import { DEFAULT_CLASS_SUBJECTS } from "./types" import { ValidationError } from "@/shared/lib/action-utils" - -const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS - -export const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_STRINGS.includes(v) +// G3-036 去重:isClassSubject 已提取至 lib/admin-class-mappers,此处 re-export 保持向后兼容 +export { isClassSubject } from "./lib/admin-class-mappers" +import { isClassSubject } from "./lib/admin-class-mappers" export const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 => n >= 1 && n <= 7 && Number.isInteger(n) diff --git a/src/modules/classes/components/admin-classes-view.tsx b/src/modules/classes/components/admin-classes-view.tsx index cc85e0c..9a1e6fd 100644 --- a/src/modules/classes/components/admin-classes-view.tsx +++ b/src/modules/classes/components/admin-classes-view.tsx @@ -1,7 +1,7 @@ "use client" import { useMemo } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -43,14 +43,14 @@ export function AdminClassesClient({ try { const res = await createAdminClassAction(undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) data.setCreateOpen(false) router.refresh() } else { - toast.error(res.message || t("list.failedCreate")) + notify.error(res.message || t("list.failedCreate")) } } catch { - toast.error(t("list.failedCreate")) + notify.error(t("list.failedCreate")) } finally { data.setIsWorking(false) } @@ -62,14 +62,14 @@ export function AdminClassesClient({ try { const res = await updateAdminClassAction(data.editItem.id, undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) data.setEditItem(null) router.refresh() } else { - toast.error(res.message || t("list.failedUpdate")) + notify.error(res.message || t("list.failedUpdate")) } } catch { - toast.error(t("list.failedUpdate")) + notify.error(t("list.failedUpdate")) } finally { data.setIsWorking(false) } @@ -81,14 +81,14 @@ export function AdminClassesClient({ try { const res = await deleteAdminClassAction(data.deleteItem.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) data.setDeleteItem(null) router.refresh() } else { - toast.error(res.message || t("list.failedDelete")) + notify.error(res.message || t("list.failedDelete")) } } catch { - toast.error(t("list.failedDelete")) + notify.error(t("list.failedDelete")) } finally { data.setIsWorking(false) } diff --git a/src/modules/classes/components/class-detail/edit-class-dialog.tsx b/src/modules/classes/components/class-detail/edit-class-dialog.tsx index e0c5fc9..a1dc282 100644 --- a/src/modules/classes/components/class-detail/edit-class-dialog.tsx +++ b/src/modules/classes/components/class-detail/edit-class-dialog.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" @@ -46,14 +46,14 @@ export function EditClassDialog({ try { const res = await updateTeacherClassAction(classId, null, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) onOpenChange(false) router.refresh() } else { - toast.error(res.message || t("list.failedUpdate")) + notify.error(res.message || t("list.failedUpdate")) } } catch { - toast.error(t("list.failedUpdate")) + notify.error(t("list.failedUpdate")) } finally { setIsWorking(false) } diff --git a/src/modules/classes/components/class-invitation-manager.tsx b/src/modules/classes/components/class-invitation-manager.tsx index 11e5674..709d3cb 100644 --- a/src/modules/classes/components/class-invitation-manager.tsx +++ b/src/modules/classes/components/class-invitation-manager.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { useQueryClient } from "@tanstack/react-query" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Copy, Plus, Ban, Clock, Hash } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -85,9 +85,9 @@ export function ClassInvitationManager({ const handleCopy = async (code: string): Promise => { try { await navigator.clipboard.writeText(code) - toast.success(t("copied")) + notify.success(t("copied")) } catch { - toast.error(t("copy")) + notify.error(t("copy")) } } @@ -240,7 +240,7 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP formData.set("note", note) const result = await createClassInvitationCodeAction(null, formData) if (result.success && result.data) { - toast.success(t("generateSuccess")) + notify.success(t("generateSuccess")) onCreated({ id: result.data.id, code: result.data.code, @@ -256,10 +256,10 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP }) onClose() } else { - toast.error(result.message ?? t("generateFailed")) + notify.error(result.message ?? t("generateFailed")) } } catch { - toast.error(t("generateFailed")) + notify.error(t("generateFailed")) } finally { setIsSubmitting(false) } diff --git a/src/modules/classes/components/grade-classes-view.tsx b/src/modules/classes/components/grade-classes-view.tsx index 0d079e3..b140a57 100644 --- a/src/modules/classes/components/grade-classes-view.tsx +++ b/src/modules/classes/components/grade-classes-view.tsx @@ -1,7 +1,7 @@ "use client" import { useMemo } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -33,14 +33,14 @@ export function GradeClassesClient({ try { const res = await createGradeClassAction(undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) data.setCreateOpen(false) router.refresh() } else { - toast.error(res.message || t("list.failedCreate")) + notify.error(res.message || t("list.failedCreate")) } } catch { - toast.error(t("list.failedCreate")) + notify.error(t("list.failedCreate")) } finally { data.setIsWorking(false) } @@ -52,14 +52,14 @@ export function GradeClassesClient({ try { const res = await updateGradeClassAction(data.editItem.id, undefined, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) data.setEditItem(null) router.refresh() } else { - toast.error(res.message || t("list.failedUpdate")) + notify.error(res.message || t("list.failedUpdate")) } } catch { - toast.error(t("list.failedUpdate")) + notify.error(t("list.failedUpdate")) } finally { data.setIsWorking(false) } @@ -71,14 +71,14 @@ export function GradeClassesClient({ try { const res = await deleteGradeClassAction(data.deleteItem.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) data.setDeleteItem(null) router.refresh() } else { - toast.error(res.message || t("list.failedDelete")) + notify.error(res.message || t("list.failedDelete")) } } catch { - toast.error(t("list.failedDelete")) + notify.error(t("list.failedDelete")) } finally { data.setIsWorking(false) } diff --git a/src/modules/classes/components/my-classes-grid.tsx b/src/modules/classes/components/my-classes-grid.tsx index c52ca3f..4b196d3 100644 --- a/src/modules/classes/components/my-classes-grid.tsx +++ b/src/modules/classes/components/my-classes-grid.tsx @@ -12,7 +12,7 @@ import { MapPin, GraduationCap, } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Badge } from "@/shared/components/ui/badge" @@ -65,15 +65,15 @@ export function MyClassesGrid({ try { const res = await joinClassByInvitationCodeAction(null, formData) if (res.success) { - toast.success(res.message || t("invitation.joinSuccess")) + notify.success(res.message || t("invitation.joinSuccess")) setJoinOpen(false) setJoinSubject("") router.refresh() } else { - toast.error(res.message || t("invitation.joinFailed")) + notify.error(res.message || t("invitation.joinFailed")) } } catch { - toast.error(t("invitation.joinFailed")) + notify.error(t("invitation.joinFailed")) } finally { setIsWorking(false) } @@ -220,13 +220,13 @@ function ClassTicket({ try { const res = await ensureClassInvitationCodeAction(c.id) if (res.success) { - toast.success(res.message || t("invitation.generateSuccess")) + notify.success(res.message || t("invitation.generateSuccess")) router.refresh() } else { - toast.error(res.message || t("invitation.generateFailed")) + notify.error(res.message || t("invitation.generateFailed")) } } catch { - toast.error(t("invitation.generateFailed")) + notify.error(t("invitation.generateFailed")) } finally { onWorkingChange(false) } @@ -237,13 +237,13 @@ function ClassTicket({ try { const res = await regenerateClassInvitationCodeAction(c.id) if (res.success) { - toast.success(res.message || t("invitation.regenerateSuccess")) + notify.success(res.message || t("invitation.regenerateSuccess")) router.refresh() } else { - toast.error(res.message || t("list.failedRegenerate")) + notify.error(res.message || t("list.failedRegenerate")) } } catch { - toast.error(t("list.failedRegenerate")) + notify.error(t("list.failedRegenerate")) } finally { onWorkingChange(false) } @@ -254,9 +254,9 @@ function ClassTicket({ if (!code) return try { await navigator.clipboard.writeText(code) - toast.success(t("myClasses.codeCopied")) + notify.success(t("myClasses.codeCopied")) } catch { - toast.error(t("list.failedCopy")) + notify.error(t("list.failedCopy")) } } diff --git a/src/modules/classes/components/schedule-create-dialog.tsx b/src/modules/classes/components/schedule-create-dialog.tsx index 27b0081..852eaaf 100644 --- a/src/modules/classes/components/schedule-create-dialog.tsx +++ b/src/modules/classes/components/schedule-create-dialog.tsx @@ -3,7 +3,7 @@ import { useState } from "react" 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 { @@ -58,14 +58,14 @@ export function ScheduleCreateDialog({ formData.set("weekday", String(weekday)) const res = await createClassScheduleItemAction(null, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) onOpenChange(false) router.refresh() } else { - toast.error(res.message || t("list.failedCreate")) + notify.error(res.message || t("list.failedCreate")) } } catch { - toast.error(t("list.failedCreate")) + notify.error(t("list.failedCreate")) } finally { setIsWorking(false) } diff --git a/src/modules/classes/components/schedule-delete-dialog.tsx b/src/modules/classes/components/schedule-delete-dialog.tsx index 0ea6172..79babe5 100644 --- a/src/modules/classes/components/schedule-delete-dialog.tsx +++ b/src/modules/classes/components/schedule-delete-dialog.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { AlertDialog, @@ -40,14 +40,14 @@ export function ScheduleDeleteDialog({ try { const res = await deleteClassScheduleItemAction(deleteItem.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) onClose() router.refresh() } else { - toast.error(res.message || t("list.failedDelete")) + notify.error(res.message || t("list.failedDelete")) } } catch { - toast.error(t("list.failedDelete")) + notify.error(t("list.failedDelete")) } finally { setIsWorking(false) } diff --git a/src/modules/classes/components/schedule-edit-dialog.tsx b/src/modules/classes/components/schedule-edit-dialog.tsx index 6d4576e..feb7b7d 100644 --- a/src/modules/classes/components/schedule-edit-dialog.tsx +++ b/src/modules/classes/components/schedule-edit-dialog.tsx @@ -3,7 +3,7 @@ import { useState } from "react" 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 { @@ -59,14 +59,14 @@ export function ScheduleEditDialog({ formData.set("weekday", editWeekday) const res = await updateClassScheduleItemAction(editItem.id, null, formData) if (res.success) { - toast.success(res.message) + notify.success(res.message) onClose() router.refresh() } else { - toast.error(res.message || t("list.failedUpdate")) + notify.error(res.message || t("list.failedUpdate")) } } catch { - toast.error(t("list.failedUpdate")) + notify.error(t("list.failedUpdate")) } finally { setIsWorking(false) } diff --git a/src/modules/classes/components/students-table.tsx b/src/modules/classes/components/students-table.tsx index 9c1a859..1e372f0 100644 --- a/src/modules/classes/components/students-table.tsx +++ b/src/modules/classes/components/students-table.tsx @@ -4,7 +4,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { MoreHorizontal, UserCheck, UserX } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" @@ -33,13 +33,13 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) { try { const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.refresh() } else { - toast.error(res.message) + notify.error(res.message) } } catch { - toast.error(t("list.failedStatus")) + notify.error(t("list.failedStatus")) } finally { setWorkingKey(null) } diff --git a/src/modules/classes/data-access-admin.ts b/src/modules/classes/data-access-admin.ts index 2eb8291..1b80ea4 100644 --- a/src/modules/classes/data-access-admin.ts +++ b/src/modules/classes/data-access-admin.ts @@ -18,32 +18,30 @@ import { } from "@/shared/db/schema" import { ROLE_NAMES } from "@/shared/types/permissions" import { createModuleLogger } from "@/shared/lib/logger" -import { DEFAULT_CLASS_SUBJECTS } from "./types" import type { AdminClassListItem, - ClassSubject, - ClassSubjectTeacherAssignment, CreateTeacherClassInput, - TeacherOption, UpdateTeacherClassInput, } from "./types" import { - compareClassLike, generateUniqueInvitationCode, getClassSubjects, isDuplicateInvitationCodeError, } from "./data-access" +// G3-020 / G3-036 去重:isClassSubject / toClassSubject / +// buildSubjectsByClassIdMap / composeAdminClassList 已提取至 lib/admin-class-mappers +import { + buildSubjectsByClassIdMap, + composeAdminClassList, + type AdminClassRow, + type SubjectTeacherRow, +} from "./lib/admin-class-mappers" const log = createModuleLogger("classes") -const isClassSubject = (v: unknown): v is ClassSubject => - typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v) - -const toClassSubject = (v: string): ClassSubject | null => - isClassSubject(v) ? v : null export const getAdminClassesRaw = async (): Promise => { const [rows, subjectRows] = await Promise.all([ - (async () => { + (async (): Promise => { try { return await db .select({ @@ -138,53 +136,11 @@ export const getAdminClassesRaw = async (): Promise => { .from(classSubjectTeachers) .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId)) .leftJoin(users, eq(users.id, classSubjectTeachers.teacherId)) - .orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)), + .orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)) as Promise, ]) - const subjectsByClassId = new Map>() - for (const r of subjectRows) { - const subject = toClassSubject(r.subject) - if (!subject) continue - const teacher = - typeof r.teacherId === "string" && r.teacherId.length > 0 - ? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" } - : null - const bySubject = subjectsByClassId.get(r.classId) ?? new Map() - bySubject.set(subject, teacher) - subjectsByClassId.set(r.classId, bySubject) - } - - const list = rows.map((r) => { - const bySubject = subjectsByClassId.get(r.id) - const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({ - subject, - teacher: bySubject?.get(subject) ?? null, - })) - - return { - id: r.id, - schoolName: r.schoolName, - schoolId: r.schoolId, - name: r.name, - grade: r.grade, - gradeId: r.gradeId, - homeroom: r.homeroom, - room: r.room, - invitationCode: r.invitationCode ?? null, - teacher: { - id: r.teacherId, - name: r.teacherName ?? "Unnamed", - email: r.teacherEmail, - }, - subjectTeachers, - studentCount: Number(r.studentCount ?? 0), - createdAt: r.createdAt.toISOString(), - updatedAt: r.updatedAt.toISOString(), - } - }) - - list.sort(compareClassLike) - return list + const subjectsByClassId = buildSubjectsByClassIdMap(subjectRows) + return composeAdminClassList(rows, subjectsByClassId) } export const getAdminClasses = cacheFn(getAdminClassesRaw, { tags: ["classes", "classes:list"], @@ -203,7 +159,7 @@ export const getGradeManagedClassesRaw = async (userId: string): Promise g.id) const [rows, subjectRows] = await Promise.all([ - (async () => { + (async (): Promise => { try { return await db .select({ @@ -268,53 +224,11 @@ export const getGradeManagedClassesRaw = async (userId: string): Promise, ]) - const subjectsByClassId = new Map>() - for (const r of subjectRows) { - const subject = toClassSubject(r.subject) - if (!subject) continue - const teacher = - typeof r.teacherId === "string" && r.teacherId.length > 0 - ? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" } - : null - const bySubject = subjectsByClassId.get(r.classId) ?? new Map() - bySubject.set(subject, teacher) - subjectsByClassId.set(r.classId, bySubject) - } - - const list = rows.map((r) => { - const bySubject = subjectsByClassId.get(r.id) - const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({ - subject, - teacher: bySubject?.get(subject) ?? null, - })) - - return { - id: r.id, - schoolName: r.schoolName, - schoolId: r.schoolId, - name: r.name, - grade: r.grade, - gradeId: r.gradeId, - homeroom: r.homeroom, - room: r.room, - invitationCode: r.invitationCode ?? null, - teacher: { - id: r.teacherId, - name: r.teacherName ?? "Unnamed", - email: r.teacherEmail, - }, - subjectTeachers, - studentCount: Number(r.studentCount ?? 0), - createdAt: r.createdAt.toISOString(), - updatedAt: r.updatedAt.toISOString(), - } - }) - - list.sort(compareClassLike) - return list + const subjectsByClassId = buildSubjectsByClassIdMap(subjectRows) + return composeAdminClassList(rows, subjectsByClassId) } export const getGradeManagedClasses = cacheFn(getGradeManagedClassesRaw, { tags: ["classes"], diff --git a/src/modules/classes/data-access-students.ts b/src/modules/classes/data-access-students.ts index c4bfa51..93cb0bb 100644 --- a/src/modules/classes/data-access-students.ts +++ b/src/modules/classes/data-access-students.ts @@ -282,9 +282,12 @@ export const getClassStudentsRaw = async ( } if (q && q.length > 0) { - const needle = `%${q}%` + // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描;转义 LIKE 通配符 + // MySQL 默认 collation(utf8mb4_*_ci)不区分大小写,无需 LOWER() + const escapeLike = (s: string) => s.replace(/[%_\\]/g, "\\$&") + const needle = `${escapeLike(q)}%` conditions.push( - sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})` + sql`(${users.name} LIKE ${needle} OR ${users.email} LIKE ${needle})` ) } diff --git a/src/modules/classes/data-access-teacher.ts b/src/modules/classes/data-access-teacher.ts index d5db907..912fc26 100644 --- a/src/modules/classes/data-access-teacher.ts +++ b/src/modules/classes/data-access-teacher.ts @@ -37,13 +37,10 @@ import { generateUniqueInvitationCode, isDuplicateInvitationCodeError, } from "./data-access-invitations" +// G3-036 去重:isClassSubject / toClassSubject 已提取至 lib/admin-class-mappers +import { toClassSubject } from "./lib/admin-class-mappers" const log = createModuleLogger("classes") -const isClassSubject = (v: unknown): v is ClassSubject => - typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v) - -const toClassSubject = (v: string): ClassSubject | null => - isClassSubject(v) ? v : null export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Promise => { const teacherId = params?.teacherId ?? (await getSessionTeacherId()) @@ -90,13 +87,21 @@ export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Pro list.sort(compareClassLike) - // Fetch recent assignments for trends and schedule + // N+1 优化:单条 SQL 获取所有可访问班级的课表,按 classId 分组到 Map, + // 替代原先每班级 1 条 SQL 的 N+1 模式。 + const allSchedule = await getClassSchedule({ teacherId }) + const scheduleByClassId = new Map() + for (const item of allSchedule) { + const arr = scheduleByClassId.get(item.classId) ?? [] + arr.push(item) + scheduleByClassId.set(item.classId, arr) + } + + // Fetch recent assignments for trends(getClassHomeworkInsights 已通过 cacheFn 缓存) const listWithTrends = await Promise.all( list.map(async (c) => { - const [insights, schedule] = await Promise.all([ - getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }), - getClassSchedule({ classId: c.id, teacherId }), - ]) + const insights = await getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }) + const schedule = scheduleByClassId.get(c.id) ?? [] const recentAssignments = insights ? insights.assignments.map((a) => ({ @@ -125,6 +130,32 @@ export const getTeacherClasses = cacheFn(getTeacherClassesRaw, { keyParts: ["classes", "teacher", "list"], }) +/** + * 批量获取多个班级的活跃学生 ID(Map)。 + * + * 单条 `inArray` 查询替代 N 次 `getActiveStudentIdsByClassId` 调用, + * 供跨模块调用使用以消除 N+1 查询。 + */ +export const getActiveStudentIdsByClassIdsBatch = async ( + classIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(classIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ classId: classEnrollments.classId, studentId: classEnrollments.studentId }) + .from(classEnrollments) + .where(and(inArray(classEnrollments.classId, uniqueIds), eq(classEnrollments.status, "active"))) + + for (const r of rows) { + const list = result.get(r.classId) ?? [] + list.push(r.studentId) + result.set(r.classId, list) + } + return result +} + export const getTeacherOptionsRaw = async (): Promise => { const rows = await db .select({ id: users.id, name: users.name, email: users.email }) @@ -345,99 +376,106 @@ export async function enrollTeacherByInvitationCode( if (!cls) throw new Error("Invalid invitation code") if (cls.teacherId === tid) return cls.id - const subjectValue = typeof subject === "string" ? subject.trim() : "" - const [existing] = await db - .select({ id: classSubjectTeachers.classId }) - .from(classSubjectTeachers) - .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid))) - .limit(1) - - if (existing && !subjectValue) return cls.id - if (subjectValue) { - const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1) - if (!subRow) throw new Error("Subject not found") - const sid = subRow.id - - const [mapping] = await db - .select({ teacherId: classSubjectTeachers.teacherId }) + // F-09:将多步读写包装在单事务中保证原子性,避免中途失败留下脏数据。 + // 事务内统一使用 tx 执行查询,使读写看到一致的事务内状态。 + // 返回 [classId, shouldConsume]:早退(已分配)场景不消耗邀请码,保留原行为。 + const [enrolledClassId, shouldConsume] = await db.transaction<[string, boolean]>(async (tx) => { + const subjectValue = typeof subject === "string" ? subject.trim() : "" + const [existing] = await tx + .select({ id: classSubjectTeachers.classId }) .from(classSubjectTeachers) - .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid))) + .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid))) .limit(1) - if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned") - if (mapping?.teacherId === tid) return cls.id - if (!mapping) { - await db - .insert(classSubjectTeachers) - .values({ classId: cls.id, subjectId: sid, teacherId: null }) - .onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } }) + if (existing && !subjectValue) return [cls.id, false] + if (subjectValue) { + const [subRow] = await tx.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1) + if (!subRow) throw new Error("Subject not found") + const sid = subRow.id + + const [mapping] = await tx + .select({ teacherId: classSubjectTeachers.teacherId }) + .from(classSubjectTeachers) + .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid))) + .limit(1) + + if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned") + if (mapping?.teacherId === tid) return [cls.id, false] + if (!mapping) { + await tx + .insert(classSubjectTeachers) + .values({ classId: cls.id, subjectId: sid, teacherId: null }) + .onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } }) + } + + const [existingSubject] = await tx + .select({ id: classSubjectTeachers.classId }) + .from(classSubjectTeachers) + .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid))) + .limit(1) + + if (existingSubject) return [cls.id, false] + + await tx + .update(classSubjectTeachers) + .set({ teacherId: tid }) + .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId))) + + const [assigned] = await tx + .select({ id: classSubjectTeachers.classId }) + .from(classSubjectTeachers) + .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid))) + .limit(1) + + if (!assigned) throw new Error("Subject already assigned") + } else { + const subjectRows = await tx + .select({ id: classSubjectTeachers.subjectId, name: subjects.name }) + .from(classSubjectTeachers) + .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId)) + .where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId))) + + const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s)) + if (!preferred) throw new Error("Class already has assigned teachers") + const subjectRow = subjectRows.find((r) => r.name === preferred) + if (!subjectRow) throw new Error("Subject not found") + const sid = subjectRow.id + + await tx + .update(classSubjectTeachers) + .set({ teacherId: tid }) + .where( + and( + eq(classSubjectTeachers.classId, cls.id), + eq(classSubjectTeachers.subjectId, sid), + isNull(classSubjectTeachers.teacherId) + ) + ) + + const [assigned] = await tx + .select({ id: classSubjectTeachers.classId }) + .from(classSubjectTeachers) + .where( + and( + eq(classSubjectTeachers.classId, cls.id), + eq(classSubjectTeachers.subjectId, sid), + eq(classSubjectTeachers.teacherId, tid) + ) + ) + .limit(1) + + if (!assigned) throw new Error("Class already has assigned teachers") } - const [existingSubject] = await db - .select({ id: classSubjectTeachers.classId }) - .from(classSubjectTeachers) - .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid))) - .limit(1) + return [cls.id, true] + }) - if (existingSubject) return cls.id - - await db - .update(classSubjectTeachers) - .set({ teacherId: tid }) - .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId))) - - const [assigned] = await db - .select({ id: classSubjectTeachers.classId }) - .from(classSubjectTeachers) - .where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid))) - .limit(1) - - if (!assigned) throw new Error("Subject already assigned") - } else { - const subjectRows = await db - .select({ id: classSubjectTeachers.subjectId, name: subjects.name }) - .from(classSubjectTeachers) - .innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId)) - .where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId))) - - const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s)) - if (!preferred) throw new Error("Class already has assigned teachers") - const subjectRow = subjectRows.find((r) => r.name === preferred) - if (!subjectRow) throw new Error("Subject not found") - const sid = subjectRow.id - - await db - .update(classSubjectTeachers) - .set({ teacherId: tid }) - .where( - and( - eq(classSubjectTeachers.classId, cls.id), - eq(classSubjectTeachers.subjectId, sid), - isNull(classSubjectTeachers.teacherId) - ) - ) - - const [assigned] = await db - .select({ id: classSubjectTeachers.classId }) - .from(classSubjectTeachers) - .where( - and( - eq(classSubjectTeachers.classId, cls.id), - eq(classSubjectTeachers.subjectId, sid), - eq(classSubjectTeachers.teacherId, tid) - ) - ) - .limit(1) - - if (!assigned) throw new Error("Class already has assigned teachers") - } - - // 消耗新表邀请码(旧表无计数,跳过) - if (result.codeId) { + // 消耗新表邀请码(旧表无计数,跳过) - 在事务外执行 + if (shouldConsume && result.codeId) { await consumeInvitationCode(code) } - return cls.id + return enrolledClassId } export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise { diff --git a/src/modules/classes/data-access.ts b/src/modules/classes/data-access.ts index 63c1fea..2ab96f6 100644 --- a/src/modules/classes/data-access.ts +++ b/src/modules/classes/data-access.ts @@ -3,6 +3,7 @@ import "server-only"; import { and, asc, eq, inArray } from "drizzle-orm" import { db } from "@/shared/db" +import { cacheFn } from "@/shared/lib/cache" import { classes, classEnrollments, @@ -14,6 +15,9 @@ import { import { ROLE_NAMES } from "@/shared/types/permissions" import { DEFAULT_CLASS_SUBJECTS } from "./types" +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000 + export const getSessionTeacherId = async (): Promise => { const { auth } = await import("@/auth") const session = await auth() @@ -36,7 +40,7 @@ export const getTeacherIdForMutations = async (): Promise => { return teacherId } -export const getClassSubjects = async (): Promise => { +export const getClassSubjectsRaw = async (): Promise => { const rows = await db.query.subjects.findMany({ orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)], }) @@ -45,6 +49,11 @@ export const getClassSubjects = async (): Promise => { // P1-5: subjects 表为空时回退到默认科目列表,避免班级创建流程中断 return Array.from(new Set(names.length > 0 ? names : DEFAULT_CLASS_SUBJECTS)) } +export const getClassSubjects = cacheFn(getClassSubjectsRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getClassSubjects"], +}) const normalizeSortText = (v: string | null | undefined): string => typeof v === "string" ? v.trim().toLowerCase() : "" @@ -80,7 +89,7 @@ export const compareClassLike = ( return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room)) } -export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise => { +export const getAccessibleClassIdsForTeacherRaw = async (teacherId: string): Promise => { const [ownedIds, assignedIds] = await Promise.all([ db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)), db @@ -90,6 +99,11 @@ export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promis ]) return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)])) } +export const getAccessibleClassIdsForTeacher = cacheFn(getAccessibleClassIdsForTeacherRaw, { + tags: ["classes"], + ttl: 60, + keyParts: ["classes", "getAccessibleClassIdsForTeacher"], +}) /** * Verify that a teacher owns a class (teacherId match on classes row). @@ -104,7 +118,7 @@ export async function verifyTeacherOwnsClass(classId: string, teacherId: string) return Boolean(owned) } -export const getClassGradeIdsByClassIds = async (classIds: string[]): Promise> => { +export const getClassGradeIdsByClassIdsRaw = async (classIds: string[]): Promise> => { if (classIds.length === 0) return new Map() const rows = await db .select({ id: classes.id, gradeId: classes.gradeId }) @@ -118,20 +132,30 @@ export const getClassGradeIdsByClassIds = async (classIds: string[]): Promise => { +export const getTeacherSubjectIdsForClassRaw = async (teacherId: string, classId: string): Promise => { const rows = await db .select({ subjectId: classSubjectTeachers.subjectId }) .from(classSubjectTeachers) .where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId))) return Array.from(new Set(rows.map((r) => String(r.subjectId)))) } +export const getTeacherSubjectIdsForClass = cacheFn(getTeacherSubjectIdsForClassRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getTeacherSubjectIdsForClass"], +}) /** * 获取班级的教师 ID(班主任)。 * 供跨模块调用使用,避免直接查询 classes 表。 */ -export const getClassTeacherById = async (classId: string): Promise => { +export const getClassTeacherByIdRaw = async (classId: string): Promise => { const [row] = await db .select({ teacherId: classes.teacherId }) .from(classes) @@ -139,24 +163,34 @@ export const getClassTeacherById = async (classId: string): Promise => { +export const getStudentIdsByClassIdRaw = async (classId: string): Promise => { const rows = await db .select({ studentId: classEnrollments.studentId }) .from(classEnrollments) .where(eq(classEnrollments.classId, classId)) return rows.map((r) => r.studentId) } +export const getStudentIdsByClassId = cacheFn(getStudentIdsByClassIdRaw, { + tags: ["classes"], + ttl: 60, + keyParts: ["classes", "getStudentIdsByClassId"], +}) /** * 获取多个班级的所有学生 ID(不限状态)。 * 供跨模块调用使用,避免直接查询 classEnrollments 表。 */ -export const getStudentIdsByClassIds = async (classIds: string[]): Promise => { +export const getStudentIdsByClassIdsRaw = async (classIds: string[]): Promise => { if (classIds.length === 0) return [] const rows = await db .select({ studentId: classEnrollments.studentId }) @@ -164,24 +198,34 @@ export const getStudentIdsByClassIds = async (classIds: string[]): Promise r.studentId))) } +export const getStudentIdsByClassIds = cacheFn(getStudentIdsByClassIdsRaw, { + tags: ["classes"], + ttl: 60, + keyParts: ["classes", "getStudentIdsByClassIds"], +}) /** * 获取班级所有活跃学生 ID(status = 'active')。 * 供跨模块调用使用,避免直接查询 classEnrollments 表。 */ -export const getActiveStudentIdsByClassId = async (classId: string): Promise => { +export const getActiveStudentIdsByClassIdRaw = async (classId: string): Promise => { const rows = await db .select({ studentId: classEnrollments.studentId }) .from(classEnrollments) .where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active"))) return rows.map((r) => r.studentId) } +export const getActiveStudentIdsByClassId = cacheFn(getActiveStudentIdsByClassIdRaw, { + tags: ["classes"], + ttl: 60, + keyParts: ["classes", "getActiveStudentIdsByClassId"], +}) /** * 获取班级所有活跃学生基本信息(id/name/email),按姓名升序。 * 供跨模块调用使用(如考勤点名),避免直接查询 classEnrollments 表。 */ -export const getClassActiveStudentsWithInfo = async ( +export const getClassActiveStudentsWithInfoRaw = async ( classId: string ): Promise> => { const rows = await db @@ -192,20 +236,30 @@ export const getClassActiveStudentsWithInfo = async ( .orderBy(asc(users.name)) return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email })) } +export const getClassActiveStudentsWithInfo = cacheFn(getClassActiveStudentsWithInfoRaw, { + tags: ["classes"], + ttl: 60, + keyParts: ["classes", "getClassActiveStudentsWithInfo"], +}) /** * 获取教师在一个班级所教的科目 ID 列表。 * 参数顺序为 (classId, teacherId),供跨模块调用使用。 */ -export const getTeacherSubjectIdsByClass = async (classId: string, teacherId: string): Promise => { +export const getTeacherSubjectIdsByClassRaw = async (classId: string, teacherId: string): Promise => { return getTeacherSubjectIdsForClass(teacherId, classId) } +export const getTeacherSubjectIdsByClass = cacheFn(getTeacherSubjectIdsByClassRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getTeacherSubjectIdsByClass"], +}) /** * 获取多个班级的所有教师 ID(班主任 + 任课教师)。 * 供跨模块调用使用,避免直接查询 classes / classSubjectTeachers 表。 */ -export const getTeacherIdsByClassIds = async (classIds: string[]): Promise => { +export const getTeacherIdsByClassIdsRaw = async (classIds: string[]): Promise => { if (classIds.length === 0) return [] const [homeroomRows, subjectRows] = await Promise.all([ db @@ -226,12 +280,17 @@ export const getTeacherIdsByClassIds = async (classIds: string[]): Promise => { +export const getStudentActiveClassIdRaw = async (studentId: string): Promise => { const [row] = await db .select({ classId: classEnrollments.classId }) .from(classEnrollments) @@ -240,12 +299,17 @@ export const getStudentActiveClassId = async (studentId: string): Promise => { const [row] = await db @@ -257,12 +321,17 @@ export const getStudentActiveClass = async ( .limit(1) return row ?? null } +export const getStudentActiveClass = cacheFn(getStudentActiveClassRaw, { + tags: ["classes"], + ttl: 60, + keyParts: ["classes", "getStudentActiveClass"], +}) /** * 获取学生当前活跃班级对应的年级 ID。 * 供跨模块调用使用,避免直接查询 classEnrollments/classes 表。 */ -export const getStudentActiveGradeId = async (studentId: string): Promise => { +export const getStudentActiveGradeIdRaw = async (studentId: string): Promise => { const [row] = await db .select({ gradeId: classes.gradeId }) .from(classEnrollments) @@ -272,12 +341,17 @@ export const getStudentActiveGradeId = async (studentId: string): Promise => { +export const getClassExistsRaw = async (classId: string): Promise => { const [row] = await db .select({ id: classes.id }) .from(classes) @@ -285,12 +359,17 @@ export const getClassExists = async (classId: string): Promise => { .limit(1) return Boolean(row) } +export const getClassExists = cacheFn(getClassExistsRaw, { + tags: ["classes"], + ttl: 60, + keyParts: ["classes", "getClassExists"], +}) /** * 获取班级名称。 * 供跨模块调用使用,避免直接查询 classes 表。 */ -export const getClassNameById = async (classId: string): Promise => { +export const getClassNameByIdRaw = async (classId: string): Promise => { const [row] = await db .select({ name: classes.name }) .from(classes) @@ -298,12 +377,17 @@ export const getClassNameById = async (classId: string): Promise .limit(1) return row?.name ?? null } +export const getClassNameById = cacheFn(getClassNameByIdRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getClassNameById"], +}) /** * 获取班级关联的年级 ID。 * 供跨模块调用使用,避免直接查询 classes 表。 */ -export const getClassGradeId = async (classId: string): Promise => { +export const getClassGradeIdRaw = async (classId: string): Promise => { const [row] = await db .select({ gradeId: classes.gradeId }) .from(classes) @@ -311,12 +395,17 @@ export const getClassGradeId = async (classId: string): Promise = .limit(1) return row?.gradeId ?? null } +export const getClassGradeId = cacheFn(getClassGradeIdRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getClassGradeId"], +}) /** * 获取多个班级关联的年级 ID 列表(去重,过滤空值)。 * 供跨模块调用使用,避免直接查询 classes 表。 */ -export const getGradeIdsByClassIds = async (classIds: string[]): Promise => { +export const getGradeIdsByClassIdsRaw = async (classIds: string[]): Promise => { if (classIds.length === 0) return [] const rows = await db .selectDistinct({ gradeId: classes.gradeId }) @@ -326,12 +415,17 @@ export const getGradeIdsByClassIds = async (classIds: string[]): Promise r.gradeId) .filter((id): id is string => typeof id === "string" && id.length > 0) } +export const getGradeIdsByClassIds = cacheFn(getGradeIdsByClassIdsRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getGradeIdsByClassIds"], +}) /** * 批量获取班级名称(Map)。 * 供跨模块调用使用,避免直接查询 classes 表。 */ -export const getClassNamesByIds = async (classIds: string[]): Promise> => { +export const getClassNamesByIdsRaw = async (classIds: string[]): Promise> => { const result = new Map() const uniqueIds = Array.from(new Set(classIds.filter((v): v is string => typeof v === "string" && v.length > 0))) if (uniqueIds.length === 0) return result @@ -344,25 +438,36 @@ export const getClassNamesByIds = async (classIds: string[]): Promise> => { +export const getClassesByGradeIdRaw = async (gradeId: string): Promise> => { if (!gradeId) return [] const rows = await db .select({ id: classes.id, name: classes.name }) .from(classes) .where(eq(classes.gradeId, gradeId)) + .limit(DEFAULT_LIMIT) return rows.map((r) => ({ id: r.id, name: r.name })) } +export const getClassesByGradeId = cacheFn(getClassesByGradeIdRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getClassesByGradeId"], +}) /** * 获取多个年级下的所有班级 ID(供 grades 模块 grade_managed scope 过滤使用)。 * 供跨模块调用使用,避免直接查询 classes 表。 */ -export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise => { +export const getClassIdsByGradeIdsRaw = async (gradeIds: string[]): Promise => { const uniqueIds = Array.from(new Set(gradeIds.filter((v): v is string => typeof v === "string" && v.length > 0))) if (uniqueIds.length === 0) return [] const rows = await db @@ -371,6 +476,11 @@ export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise r.id) } +export const getClassIdsByGradeIds = cacheFn(getClassIdsByGradeIdsRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getClassIdsByGradeIds"], +}) /** * 构建一个 Drizzle 子查询 SQL,用于过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))。 @@ -380,11 +490,137 @@ export const getClassIdsByGradeIdsSubquery = (gradeIds: string[]) => { return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds)) } +// --------------------------------------------------------------------------- +// Cross-module interfaces — scheduling selectors (avoid direct schema queries) +// --------------------------------------------------------------------------- + +/** 班级轻量信息(供排课模块选择器使用) */ +export type ClassSchedulingOption = { + id: string + name: string + grade: string +} + +/** + * 跨模块接口:获取所有班级的轻量信息(id/name/grade),供排课模块选择器使用。 + * + * 避免 scheduling 模块直接查询 classes 表。 + */ +export const getClassesForSchedulingRaw = async (): Promise => { + const rows = await db + .select({ id: classes.id, name: classes.name, grade: classes.grade }) + .from(classes) + .orderBy(asc(classes.grade), asc(classes.name)) + .limit(DEFAULT_LIMIT) + return rows.map((r) => ({ id: r.id, name: r.name, grade: r.grade })) +} +export const getClassesForScheduling = cacheFn(getClassesForSchedulingRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getClassesForScheduling"], +}) + +/** 班级科目分配(仅 subjectId + teacherId,科目名称需调用方通过 school 模块解析) */ +export type ClassSubjectAssignment = { + subjectId: string + teacherId: string | null +} + +/** + * 跨模块接口:获取指定班级的科目-教师分配(仅 subjectId + teacherId)。 + * + * 供 scheduling 模块获取班级科目任课教师,避免直查 classSubjectTeachers 表。 + * 科目名称需调用方通过 school 模块 getSubjectNameMapByIds 解析。 + */ +export const getClassSubjectAssignmentsByClassIdRaw = async ( + classId: string +): Promise => { + const rows = await db + .select({ + subjectId: classSubjectTeachers.subjectId, + teacherId: classSubjectTeachers.teacherId, + }) + .from(classSubjectTeachers) + .where(eq(classSubjectTeachers.classId, classId)) + return rows.map((r) => ({ subjectId: r.subjectId, teacherId: r.teacherId ?? null })) +} +export const getClassSubjectAssignmentsByClassId = cacheFn(getClassSubjectAssignmentsByClassIdRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getClassSubjectAssignmentsByClassId"], +}) + +/** + * 跨模块接口:获取所有班级-科目-教师分配中的去重教师 ID 列表。 + * + * 供 scheduling 模块获取"有任课的教师"列表,避免直查 classSubjectTeachers 表。 + * 教师详情需调用方通过 users 模块 getTeachersByIds 解析。 + */ +export const getDistinctTeacherIdsFromAssignmentsRaw = async (): Promise => { + const rows = await db + .selectDistinct({ teacherId: classSubjectTeachers.teacherId }) + .from(classSubjectTeachers) + .limit(DEFAULT_LIMIT) + return rows + .map((r) => r.teacherId) + .filter((id): id is string => typeof id === "string" && id.length > 0) +} +export const getDistinctTeacherIdsFromAssignments = cacheFn(getDistinctTeacherIdsFromAssignmentsRaw, { + tags: ["classes"], + ttl: 300, + keyParts: ["classes", "getDistinctTeacherIdsFromAssignments"], +}) + // Re-export from split files for backward compatibility -export * from "./data-access-stats" -export * from "./data-access-schedule" -export * from "./data-access-students" -export * from "./data-access-admin" +// G3-048: 对导出数 < 15 的子文件改为显式 re-export,便于 IDE 跳转与命名冲突检测; +// 导出数 >= 15 的子文件保留 export * 以避免遗漏符号(见下方注释)。 +export { + getClassHomeworkInsightsRaw, + getClassHomeworkInsights, + getGradeHomeworkInsightsRaw, + getGradeHomeworkInsights, + getClassesDashboardStatsRaw, + getClassesDashboardStats, +} from "./data-access-stats" +export type { ClassesDashboardStats } from "./data-access-stats" + +export { + getClassIdByScheduleId, + getStudentScheduleRaw, + getStudentSchedule, + getClassScheduleRaw, + getClassSchedule, +} from "./data-access-schedule" + +export { + getStudentsSubjectScoresRaw, + getStudentsSubjectScores, + getClassStudentSubjectScoresV2Raw, + getClassStudentSubjectScoresV2, + getStudentClassesRaw, + getStudentClasses, + getStudentClassByIdRaw, + getStudentClassById, + getClassStudentsRaw, + getClassStudents, + getStudentScopeData, + getGradeIdsForStudentIds, +} from "./data-access-students" + +export { + getAdminClassesRaw, + getAdminClasses, + getGradeManagedClassesRaw, + getGradeManagedClasses, + getManagedGradesRaw, + getManagedGrades, + createAdminClass, + updateAdminClass, + deleteAdminClass, +} from "./data-access-admin" + +// 以下子文件导出数 >= 15(invitations 22 个、teacher 18 个),显式列举易遗漏, +// 保留 export * 以保证完整性。详见 G3-048 审计建议。 export * from "./data-access-invitations" export * from "./data-access-teacher" diff --git a/src/modules/course-plans/components/course-plan-detail.tsx b/src/modules/course-plans/components/course-plan-detail.tsx index 58435c9..59b4b6f 100644 --- a/src/modules/course-plans/components/course-plan-detail.tsx +++ b/src/modules/course-plans/components/course-plan-detail.tsx @@ -5,7 +5,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { ArrowLeft, Pencil, Plus, Trash2, Download } from "lucide-react" import { DndContext, @@ -88,15 +88,15 @@ export function CoursePlanDetail({ try { const res = await deleteCoursePlanAction(plan.id) if (res.success) { - toast.success(t("toast.deleted")) + notify.success(t("toast.deleted")) trackCoursePlanEvent("plan_deleted", { planId: plan.id }) router.push(successHref ?? "/admin/course-plans") router.refresh() } else { - toast.error(res.message || t("toast.deleteFailed")) + notify.error(res.message || t("toast.deleteFailed")) } } catch { - toast.error(t("toast.deleteFailed")) + notify.error(t("toast.deleteFailed")) } finally { setIsWorking(false) setDeleteOpen(false) @@ -128,14 +128,14 @@ export function CoursePlanDetail({ try { const res = await bulkToggleItemsAction(Array.from(selectedIds), true) if (res.success) { - toast.success(t("toast.bulkMarked", { count: res.data ?? 0 })) + notify.success(t("toast.bulkMarked", { count: res.data ?? 0 })) setSelectedIds(new Set()) router.refresh() } else { - toast.error(t("toast.bulkFailed")) + notify.error(t("toast.bulkFailed")) } } catch { - toast.error(t("toast.bulkFailed")) + notify.error(t("toast.bulkFailed")) } finally { setIsWorking(false) } @@ -166,14 +166,14 @@ export function CoursePlanDetail({ try { const res = await reorderCoursePlanItemsAction({ planId: plan.id, items }) if (res.success) { - toast.success(t("detail.reorderSaved")) + notify.success(t("detail.reorderSaved")) trackCoursePlanEvent("items_reordered", { planId: plan.id, count: items.length }) router.refresh() } else { - toast.error(t("detail.reorderFailed")) + notify.error(t("detail.reorderFailed")) } } catch { - toast.error(t("detail.reorderFailed")) + notify.error(t("detail.reorderFailed")) } finally { setIsWorking(false) } @@ -200,10 +200,10 @@ export function CoursePlanDetail({ }, filename, ) - toast.success(t("export.exported")) + notify.success(t("export.exported")) trackCoursePlanEvent("plan_exported", { planId: plan.id, format: "csv" }) } catch { - toast.error(t("export.exportFailed")) + notify.error(t("export.exportFailed")) } } diff --git a/src/modules/course-plans/components/course-plan-form.tsx b/src/modules/course-plans/components/course-plan-form.tsx index 2a8238e..1ccf277 100644 --- a/src/modules/course-plans/components/course-plan-form.tsx +++ b/src/modules/course-plans/components/course-plan-form.tsx @@ -4,7 +4,7 @@ import type { JSX } from "react" import { useState } from "react" 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 { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -92,19 +92,19 @@ export function CoursePlanForm({ : null if (!res) { - toast.error(t("form.invalidState")) + notify.error(t("form.invalidState")) return } if (res.success) { - toast.success(res.message) + notify.success(res.message) router.push(successHref ?? "/admin/course-plans") router.refresh() } else { - toast.error(res.message || t("form.saveFailed")) + notify.error(res.message || t("form.saveFailed")) } } catch { - toast.error(t("form.saveFailed")) + notify.error(t("form.saveFailed")) } finally { setIsWorking(false) } diff --git a/src/modules/course-plans/components/course-plan-item-editor.tsx b/src/modules/course-plans/components/course-plan-item-editor.tsx index c551883..5dcbdd6 100644 --- a/src/modules/course-plans/components/course-plan-item-editor.tsx +++ b/src/modules/course-plans/components/course-plan-item-editor.tsx @@ -4,7 +4,7 @@ import type { JSX } from "react" import { useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Check, Trash2, X } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -59,19 +59,19 @@ export function CoursePlanItemEditor({ : null if (!res) { - toast.error(t("item.invalidState")) + notify.error(t("item.invalidState")) return } if (res.success) { - toast.success(res.message) + notify.success(res.message) onOpenChange(false) router.refresh() } else { - toast.error(res.message || t("item.saveFailed")) + notify.error(res.message || t("item.saveFailed")) } } catch { - toast.error(t("item.saveFailed")) + notify.error(t("item.saveFailed")) } finally { setIsWorking(false) } @@ -83,14 +83,14 @@ export function CoursePlanItemEditor({ try { const res = await deleteCoursePlanItemAction(item.id) if (res.success) { - toast.success(res.message) + notify.success(res.message) onOpenChange(false) router.refresh() } else { - toast.error(res.message || t("item.deleteFailed")) + notify.error(res.message || t("item.deleteFailed")) } } catch { - toast.error(t("item.deleteFailed")) + notify.error(t("item.deleteFailed")) } finally { setIsWorking(false) } @@ -102,13 +102,13 @@ export function CoursePlanItemEditor({ try { const res = await toggleCoursePlanItemCompletedAction(item.id, !item.isCompleted) if (res.success) { - toast.success(res.message) + notify.success(res.message) router.refresh() } else { - toast.error(res.message || t("item.updateFailed")) + notify.error(res.message || t("item.updateFailed")) } } catch { - toast.error(t("item.updateFailed")) + notify.error(t("item.updateFailed")) } finally { setIsWorking(false) } diff --git a/src/modules/course-plans/components/template-picker-dialog.tsx b/src/modules/course-plans/components/template-picker-dialog.tsx index 34edce3..8f56a14 100644 --- a/src/modules/course-plans/components/template-picker-dialog.tsx +++ b/src/modules/course-plans/components/template-picker-dialog.tsx @@ -4,7 +4,7 @@ import type { JSX } from "react" import { useEffect, useState } from "react" import { useTranslations } from "next-intl" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { FileText, Loader2, Search } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -110,7 +110,7 @@ export function TemplatePickerDialog({ try { const res = await copyCoursePlanAction(selectedId, [targetClassId]) if (res.success && res.data && res.data.length > 0) { - toast.success(t("templates.cloneSuccess")) + notify.success(t("templates.cloneSuccess")) trackCoursePlanEvent("plan_created_from_template", { sourcePlanId: selectedId, newPlanId: res.data[0], @@ -119,10 +119,10 @@ export function TemplatePickerDialog({ router.push(`${successHref}/${res.data[0]}/edit`) router.refresh() } else { - toast.error(res.message || t("templates.cloneFailed")) + notify.error(res.message || t("templates.cloneFailed")) } } catch { - toast.error(t("templates.cloneFailed")) + notify.error(t("templates.cloneFailed")) } finally { setCloning(false) } diff --git a/src/modules/course-plans/data-access.ts b/src/modules/course-plans/data-access.ts index 2490f91..df18f52 100644 --- a/src/modules/course-plans/data-access.ts +++ b/src/modules/course-plans/data-access.ts @@ -7,6 +7,7 @@ import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm" import { db } from "@/shared/db" import { coursePlanItems, coursePlans } from "@/shared/db/schema" import { safeParseDate } from "@/shared/lib/action-utils" +import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils" import { createModuleLogger } from "@/shared/lib/logger" import type { CoursePlan, @@ -27,10 +28,6 @@ import type { } from "./schema" const log = createModuleLogger("course-plans") -const toIso = (d: Date | null | undefined): string | null => - d ? d.toISOString() : null - -const toIsoRequired = (d: Date): string => d.toISOString() type PlanRow = typeof coursePlans.$inferSelect @@ -158,7 +155,25 @@ export const getCoursePlansRaw = async (params?: GetCoursePlansParams, scope?: C if (params?.status) conditions.push(eq(coursePlans.status, params.status)) - const query = db.select().from(coursePlans) + const query = db.select({ + id: coursePlans.id, + classId: coursePlans.classId, + subjectId: coursePlans.subjectId, + teacherId: coursePlans.teacherId, + academicYearId: coursePlans.academicYearId, + semester: coursePlans.semester, + totalHours: coursePlans.totalHours, + completedHours: coursePlans.completedHours, + weeklyHours: coursePlans.weeklyHours, + startDate: coursePlans.startDate, + endDate: coursePlans.endDate, + syllabus: coursePlans.syllabus, + objectives: coursePlans.objectives, + status: coursePlans.status, + createdBy: coursePlans.createdBy, + createdAt: coursePlans.createdAt, + updatedAt: coursePlans.updatedAt, + }).from(coursePlans) const rows = await (conditions.length > 0 ? query.where(and(...conditions)) : query @@ -181,7 +196,25 @@ export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQuerySc try { const conditions: SQL[] = [eq(coursePlans.id, id), ...buildScopeCondition(scope)] const [planRow] = await db - .select() + .select({ + id: coursePlans.id, + classId: coursePlans.classId, + subjectId: coursePlans.subjectId, + teacherId: coursePlans.teacherId, + academicYearId: coursePlans.academicYearId, + semester: coursePlans.semester, + totalHours: coursePlans.totalHours, + completedHours: coursePlans.completedHours, + weeklyHours: coursePlans.weeklyHours, + startDate: coursePlans.startDate, + endDate: coursePlans.endDate, + syllabus: coursePlans.syllabus, + objectives: coursePlans.objectives, + status: coursePlans.status, + createdBy: coursePlans.createdBy, + createdAt: coursePlans.createdAt, + updatedAt: coursePlans.updatedAt, + }) .from(coursePlans) .where(and(...conditions)) .limit(1) @@ -191,7 +224,20 @@ export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQuerySc const enriched = await enrichPlanRow(planRow) const itemRows = await db - .select() + .select({ + id: coursePlanItems.id, + planId: coursePlanItems.planId, + week: coursePlanItems.week, + topic: coursePlanItems.topic, + content: coursePlanItems.content, + hours: coursePlanItems.hours, + textbookChapter: coursePlanItems.textbookChapter, + notes: coursePlanItems.notes, + isCompleted: coursePlanItems.isCompleted, + completedAt: coursePlanItems.completedAt, + createdAt: coursePlanItems.createdAt, + updatedAt: coursePlanItems.updatedAt, + }) .from(coursePlanItems) .where(eq(coursePlanItems.planId, id)) .orderBy(asc(coursePlanItems.week), asc(coursePlanItems.createdAt)) @@ -323,14 +369,17 @@ export async function reorderCoursePlanItems( if (!existing) return - await Promise.all( - items.map((item) => - db - .update(coursePlanItems) - .set({ week: item.week }) - .where(eq(coursePlanItems.id, item.id)) + // 单事务保证原子性(F-09),并行 UPDATE 提升吞吐。 + await db.transaction(async (tx) => { + await Promise.all( + items.map((item) => + tx + .update(coursePlanItems) + .set({ week: item.week }) + .where(eq(coursePlanItems.id, item.id)) + ) ) - ) + }) } /** @@ -365,7 +414,25 @@ export async function copyCoursePlanToClasses( if (targetClassIds.length === 0) return [] const [source] = await db - .select() + .select({ + id: coursePlans.id, + classId: coursePlans.classId, + subjectId: coursePlans.subjectId, + teacherId: coursePlans.teacherId, + academicYearId: coursePlans.academicYearId, + semester: coursePlans.semester, + totalHours: coursePlans.totalHours, + completedHours: coursePlans.completedHours, + weeklyHours: coursePlans.weeklyHours, + startDate: coursePlans.startDate, + endDate: coursePlans.endDate, + syllabus: coursePlans.syllabus, + objectives: coursePlans.objectives, + status: coursePlans.status, + createdBy: coursePlans.createdBy, + createdAt: coursePlans.createdAt, + updatedAt: coursePlans.updatedAt, + }) .from(coursePlans) .where(eq(coursePlans.id, sourcePlanId)) .limit(1) @@ -373,7 +440,20 @@ export async function copyCoursePlanToClasses( if (!source) return [] const sourceItems = await db - .select() + .select({ + id: coursePlanItems.id, + planId: coursePlanItems.planId, + week: coursePlanItems.week, + topic: coursePlanItems.topic, + content: coursePlanItems.content, + hours: coursePlanItems.hours, + textbookChapter: coursePlanItems.textbookChapter, + notes: coursePlanItems.notes, + isCompleted: coursePlanItems.isCompleted, + completedAt: coursePlanItems.completedAt, + createdAt: coursePlanItems.createdAt, + updatedAt: coursePlanItems.updatedAt, + }) .from(coursePlanItems) .where(eq(coursePlanItems.planId, sourcePlanId)) @@ -450,7 +530,25 @@ export const getGradeCoursePlanProgressRaw = async (params: { gradeId: string }) // 查询年级下所有教学计划(仅 course_plans 表) const planRows = await db - .select() + .select({ + id: coursePlans.id, + classId: coursePlans.classId, + subjectId: coursePlans.subjectId, + teacherId: coursePlans.teacherId, + academicYearId: coursePlans.academicYearId, + semester: coursePlans.semester, + totalHours: coursePlans.totalHours, + completedHours: coursePlans.completedHours, + weeklyHours: coursePlans.weeklyHours, + startDate: coursePlans.startDate, + endDate: coursePlans.endDate, + syllabus: coursePlans.syllabus, + objectives: coursePlans.objectives, + status: coursePlans.status, + createdBy: coursePlans.createdBy, + createdAt: coursePlans.createdAt, + updatedAt: coursePlans.updatedAt, + }) .from(coursePlans) .where(inArray(coursePlans.classId, classIds)) .orderBy(desc(coursePlans.createdAt)) diff --git a/src/modules/dashboard/components/dashboard-time-range-filter.tsx b/src/modules/dashboard/components/dashboard-time-range-filter.tsx index de91bf2..10041e4 100644 --- a/src/modules/dashboard/components/dashboard-time-range-filter.tsx +++ b/src/modules/dashboard/components/dashboard-time-range-filter.tsx @@ -11,6 +11,11 @@ import { useTranslations } from "next-intl" /** 时间范围选项 */ export type TimeRange = "today" | "week" | "month" +/** 类型守卫:判断字符串是否为合法的 TimeRange */ +function isTimeRange(v: string): v is TimeRange { + return v === "today" || v === "week" || v === "month" +} + const RANGE_OPTIONS: { value: TimeRange; icon: typeof CalendarDays }[] = [ { value: "today", icon: CalendarDays }, { value: "week", icon: CalendarRange }, @@ -30,7 +35,8 @@ export function DashboardTimeRangeFilter({ className }: { className?: string }) const pathname = usePathname() const searchParams = useSearchParams() - const currentRange = (searchParams.get("range") as TimeRange | null) ?? "today" + const rawRange = searchParams.get("range") + const currentRange: TimeRange = rawRange && isTimeRange(rawRange) ? rawRange : "today" const handleRangeChange = useCallback( (range: TimeRange) => { diff --git a/src/modules/dashboard/hooks/use-dashboard-preferences.ts b/src/modules/dashboard/hooks/use-dashboard-preferences.ts index 28081df..1038eb8 100644 --- a/src/modules/dashboard/hooks/use-dashboard-preferences.ts +++ b/src/modules/dashboard/hooks/use-dashboard-preferences.ts @@ -17,7 +17,15 @@ function loadPreferences(role: DashboardRole): WidgetPreferences { if (typeof window === "undefined") return {} try { const stored = localStorage.getItem(`${STORAGE_KEY}-${role}`) - return stored ? (JSON.parse(stored) as WidgetPreferences) : {} + if (!stored) return {} + const parsed: unknown = JSON.parse(stored) + // 从 unknown 转换:localStorage 数据不可信,做字段类型校验 + if (typeof parsed !== "object" || parsed === null) return {} + const result: Record = {} + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "boolean") result[k] = v + } + return result } catch { return {} } diff --git a/src/modules/dashboard/hooks/use-dashboard-realtime.ts b/src/modules/dashboard/hooks/use-dashboard-realtime.ts index 093d69a..e891ffa 100644 --- a/src/modules/dashboard/hooks/use-dashboard-realtime.ts +++ b/src/modules/dashboard/hooks/use-dashboard-realtime.ts @@ -50,7 +50,9 @@ export function useDashboardRealtime( eventSource.addEventListener(eventName, (event) => { try { - const data = JSON.parse(event.data) as T + const raw: unknown = JSON.parse(event.data) + // 从 unknown 转换:SSE 数据结构由后端保证,泛型 T 的运行时校验由调用方负责 + const data = raw as T setLastMessage(data) setLastUpdatedAt(new Date()) } catch { diff --git a/src/modules/diagnostic/components/class-diagnostic-view.tsx b/src/modules/diagnostic/components/class-diagnostic-view.tsx index 9fd1d9c..a53385a 100644 --- a/src/modules/diagnostic/components/class-diagnostic-view.tsx +++ b/src/modules/diagnostic/components/class-diagnostic-view.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 { Users, AlertTriangle, TrendingUp, FileText, Filter } from "lucide-react" @@ -77,10 +77,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { const result = await service.generateClassReport(summary.classId, period) setIsGenerating(false) if (result.success) { - toast.success(result.message) + notify.success(result.message) router.refresh() } else { - toast.error(result.message || t("error.generateClassFailed")) + notify.error(result.message || t("error.generateClassFailed")) } } @@ -103,11 +103,11 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { if (result.success && result.data) { setFilteredStudents(result.data) } else { - toast.error(result.message || t("error.loadFailed")) + notify.error(result.message || t("error.loadFailed")) setFilteredStudents(null) } } catch { - toast.error(t("error.loadFailed")) + notify.error(t("error.loadFailed")) setFilteredStudents(null) } finally { setIsFiltering(false) diff --git a/src/modules/diagnostic/components/report-list.tsx b/src/modules/diagnostic/components/report-list.tsx index d35156f..dbaa888 100644 --- a/src/modules/diagnostic/components/report-list.tsx +++ b/src/modules/diagnostic/components/report-list.tsx @@ -4,7 +4,7 @@ import { useState } from "react" import { useRouter, useSearchParams } from "next/navigation" import { useCallback } from "react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { FileText, Trash2, Send, Download } from "lucide-react" import { Badge } from "@/shared/components/ui/badge" @@ -88,11 +88,11 @@ export function ReportList({ reports }: ReportListProps) { const result = await service.publishReport(publishId) setIsBusy(false) if (result.success) { - toast.success(result.message) + notify.success(result.message) setPublishId(null) router.refresh() } else { - toast.error(result.message || t("error.publishFailed")) + notify.error(result.message || t("error.publishFailed")) } } @@ -102,11 +102,11 @@ export function ReportList({ reports }: ReportListProps) { const result = await service.deleteReport(deleteId) setIsBusy(false) if (result.success) { - toast.success(result.message) + notify.success(result.message) setDeleteId(null) router.refresh() } else { - toast.error(result.message || t("error.deleteFailed")) + notify.error(result.message || t("error.deleteFailed")) } } @@ -119,7 +119,7 @@ export function ReportList({ reports }: ReportListProps) { try { const result = await service.exportReport(reportId) if (!result.success || !result.data) { - toast.error(result.message || t("error.exportFailed")) + notify.error(result.message || t("error.exportFailed")) return } // base64 -> Blob -> 下载 @@ -139,9 +139,9 @@ export function ReportList({ reports }: ReportListProps) { a.click() document.body.removeChild(a) URL.revokeObjectURL(url) - toast.success(t("reportList.exportSuccess")) + notify.success(t("reportList.exportSuccess")) } catch { - toast.error(t("error.exportFailed")) + notify.error(t("error.exportFailed")) } finally { setIsBusy(false) } diff --git a/src/modules/diagnostic/data-access.ts b/src/modules/diagnostic/data-access.ts index e77a47a..1542e4f 100644 --- a/src/modules/diagnostic/data-access.ts +++ b/src/modules/diagnostic/data-access.ts @@ -1,7 +1,7 @@ import "server-only" import { cacheFn } from "@/shared/lib/cache" -import { and, desc, eq, inArray } from "drizzle-orm" +import { and, desc, eq, inArray, sql } from "drizzle-orm" import { db } from "@/shared/db" import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema" @@ -107,7 +107,11 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise // 读取已有掌握度记录,累积计算(而非覆盖) const existingRows = await db - .select() + .select({ + knowledgePointId: knowledgePointMastery.knowledgePointId, + totalQuestions: knowledgePointMastery.totalQuestions, + correctQuestions: knowledgePointMastery.correctQuestions, + }) .from(knowledgePointMastery) .where( and( @@ -192,7 +196,11 @@ export async function updateMasteryFromHomeworkSubmission(submissionId: string): // 读取已有掌握度记录,累积计算(而非覆盖) const existingRows = await db - .select() + .select({ + knowledgePointId: knowledgePointMastery.knowledgePointId, + totalQuestions: knowledgePointMastery.totalQuestions, + correctQuestions: knowledgePointMastery.correctQuestions, + }) .from(knowledgePointMastery) .where( and( @@ -282,7 +290,11 @@ export async function updateMasteryFromExamScore( // 读取已有掌握度记录,累积计算 const existingRows = await db - .select() + .select({ + knowledgePointId: knowledgePointMastery.knowledgePointId, + totalQuestions: knowledgePointMastery.totalQuestions, + correctQuestions: knowledgePointMastery.correctQuestions, + }) .from(knowledgePointMastery) .where( and( @@ -551,3 +563,115 @@ export const getClassStudentsByKnowledgePoint = cacheFn(getClassStudentsByKnowle ttl: 300, keyParts: ["diagnostic", "getClassStudentsByKnowledgePoint"], }) + +// --------------------------------------------------------------------------- +// Cross-module batch interfaces — mastery by knowledge point id set +// --------------------------------------------------------------------------- +// 供 textbooks 模块知识图谱使用,避免直查 knowledgePointMastery 表。 +// 仅查询 knowledgePointMastery 表(diagnostic 模块自有),由调用方传入 kpIds。 +// --------------------------------------------------------------------------- + +/** 知识点掌握度快照(与 textbooks.MasteryInfo 结构兼容) */ +export type MasterySnapshot = { + masteryLevel: number + totalQuestions: number + correctQuestions: number + lastAssessedAt: Date +} + +/** + * 跨模块接口:获取学生在指定知识点集合上的掌握度。 + * + * 供 textbooks 模块知识图谱学生视图使用,避免直查 knowledgePointMastery 表。 + * + * @param studentId 学生 ID + * @param knowledgePointIds 知识点 ID 列表 + * @returns Map + */ +export const getStudentMasteryByKpIdsRaw = async ( + studentId: string, + knowledgePointIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ + knowledgePointId: knowledgePointMastery.knowledgePointId, + masteryLevel: knowledgePointMastery.masteryLevel, + totalQuestions: knowledgePointMastery.totalQuestions, + correctQuestions: knowledgePointMastery.correctQuestions, + lastAssessedAt: knowledgePointMastery.lastAssessedAt, + }) + .from(knowledgePointMastery) + .where(and( + eq(knowledgePointMastery.studentId, studentId), + inArray(knowledgePointMastery.knowledgePointId, uniqueIds), + )) + + for (const r of rows) { + result.set(r.knowledgePointId, { + masteryLevel: Number(r.masteryLevel), + totalQuestions: r.totalQuestions, + correctQuestions: r.correctQuestions, + lastAssessedAt: r.lastAssessedAt, + }) + } + return result +} +export const getStudentMasteryByKpIds = cacheFn(getStudentMasteryByKpIdsRaw, { + tags: ["diagnostic"], + ttl: 300, + keyParts: ["diagnostic", "getStudentMasteryByKpIds"], +}) + +/** + * 跨模块接口:获取多个学生在指定知识点集合上的聚合掌握度(按知识点聚合)。 + * + * 供 textbooks 模块知识图谱班级视图使用,避免直查 knowledgePointMastery 表。 + * 聚合方式:AVG(masteryLevel) / SUM(totalQuestions) / SUM(correctQuestions) / MAX(lastAssessedAt)。 + * + * @param studentIds 学生 ID 列表 + * @param knowledgePointIds 知识点 ID 列表 + * @returns Map + */ +export const getClassMasteryByKpIdsRaw = async ( + studentIds: string[], + knowledgePointIds: string[] +): Promise> => { + const result = new Map() + const uniqueStudentIds = Array.from(new Set(studentIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + const uniqueKpIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueStudentIds.length === 0 || uniqueKpIds.length === 0) return result + + const rows = await db + .select({ + knowledgePointId: knowledgePointMastery.knowledgePointId, + avgMastery: sql`AVG(${knowledgePointMastery.masteryLevel})`, + totalQuestions: sql`SUM(${knowledgePointMastery.totalQuestions})`, + correctQuestions: sql`SUM(${knowledgePointMastery.correctQuestions})`, + lastAssessedAt: sql`MAX(${knowledgePointMastery.lastAssessedAt})`, + }) + .from(knowledgePointMastery) + .where(and( + inArray(knowledgePointMastery.studentId, uniqueStudentIds), + inArray(knowledgePointMastery.knowledgePointId, uniqueKpIds), + )) + .groupBy(knowledgePointMastery.knowledgePointId) + + for (const r of rows) { + result.set(r.knowledgePointId, { + masteryLevel: Number(r.avgMastery), + totalQuestions: Number(r.totalQuestions), + correctQuestions: Number(r.correctQuestions), + lastAssessedAt: r.lastAssessedAt, + }) + } + return result +} +export const getClassMasteryByKpIds = cacheFn(getClassMasteryByKpIdsRaw, { + tags: ["diagnostic"], + ttl: 300, + keyParts: ["diagnostic", "getClassMasteryByKpIds"], +}) diff --git a/src/modules/elective/components/elective-course-form.tsx b/src/modules/elective/components/elective-course-form.tsx index 14dba8c..e64b005 100644 --- a/src/modules/elective/components/elective-course-form.tsx +++ b/src/modules/elective/components/elective-course-form.tsx @@ -4,7 +4,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -70,21 +70,21 @@ export function ElectiveCourseForm({ : null if (!res) { - toast.error(t("form.invalidFormState")) + notify.error(t("form.invalidFormState")) return } if (res.success) { - toast.success(res.message) + notify.success(res.message) // 根据 backHref 推断返回列表页路径 const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective" router.push(redirectBase) router.refresh() } else { - toast.error(res.message || t("form.saveFailed")) + notify.error(res.message || t("form.saveFailed")) } } catch { - toast.error(t("form.saveFailed")) + notify.error(t("form.saveFailed")) } finally { setIsWorking(false) } diff --git a/src/modules/elective/components/elective-course-list.tsx b/src/modules/elective/components/elective-course-list.tsx index cbfdabc..afb2ec1 100644 --- a/src/modules/elective/components/elective-course-list.tsx +++ b/src/modules/elective/components/elective-course-list.tsx @@ -4,7 +4,7 @@ import { useState, useTransition } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react" import { Badge } from "@/shared/components/ui/badge" @@ -57,10 +57,10 @@ export function ElectiveCourseList({ formData.set("courseId", courseId) const res = await action(null, formData) if (res.success) { - toast.success(res.message ?? successMsg) + notify.success(res.message ?? successMsg) router.refresh() } else { - toast.error(res.message ?? t("errors.unexpected")) + notify.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) @@ -73,10 +73,10 @@ export function ElectiveCourseList({ formData.set("courseId", courseId) const res = await deleteElectiveCourseAction(null, formData) if (res.success) { - toast.success(res.message ?? t("actions.delete")) + notify.success(res.message ?? t("actions.delete")) router.refresh() } else { - toast.error(res.message ?? t("errors.unexpected")) + notify.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) diff --git a/src/modules/elective/components/student-selection-view.tsx b/src/modules/elective/components/student-selection-view.tsx index e596c32..6d8b59f 100644 --- a/src/modules/elective/components/student-selection-view.tsx +++ b/src/modules/elective/components/student-selection-view.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from "react" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { BookOpen, CheckCircle2, XCircle } from "lucide-react" @@ -70,7 +70,7 @@ export function StudentMySelectionsSection({ } const res = await dropCourseAction(null, formData) if (res.success) { - toast.success(res.message || t("student.dropSuccess")) + notify.success(res.message || t("student.dropSuccess")) router.refresh() // 清空该课程的退课理由 setDropReasonMap((prev) => { @@ -79,7 +79,7 @@ export function StudentMySelectionsSection({ return next }) } else { - toast.error(res.message ?? t("errors.unexpected")) + notify.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) @@ -210,10 +210,10 @@ export function StudentAvailableCoursesSection({ formData.set("courseId", courseId) const res = await selectCourseAction(null, formData) if (res.success) { - toast.success(res.message || t("student.selectSuccess")) + notify.success(res.message || t("student.selectSuccess")) router.refresh() } else { - toast.error(res.message ?? t("errors.unexpected")) + notify.error(res.message ?? t("errors.unexpected")) } setPendingId(null) }) diff --git a/src/modules/elective/data-access-operations.ts b/src/modules/elective/data-access-operations.ts index 356ab68..62f867e 100644 --- a/src/modules/elective/data-access-operations.ts +++ b/src/modules/elective/data-access-operations.ts @@ -1,7 +1,7 @@ import "server-only" import { createId } from "@paralleldrive/cuid2" -import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm" +import { and, asc, eq, inArray } from "drizzle-orm" import { getTranslations } from "next-intl/server" import { db } from "@/shared/db" @@ -14,8 +14,18 @@ import { sendNotification } from "@/modules/notifications" import { getElectiveCreditLimit, getCapacityNotifyThreshold } from "./data-access-settings" import { getStudentGradeId } from "./data-access-selections" +import { + hasAnyScheduleConflict, + parseSchedule, +} from "./lib/schedule-conflict" +import { buildLotteryRankCase, partitionLottery } from "./lib/lottery" import type { CourseSelectionStatus } from "./types" +// A-02 修复:纯函数已抽离至 lib/,此处 re-export 保持向后兼容 +// (tests/integration/elective/elective-pure-functions.test.ts 仍从本文件导入) +export { normalizeDay, parseSchedule, isScheduleConflict } from "./lib/schedule-conflict" +export { buildLotteryRankCase } from "./lib/lottery" + /** * 选课模块业务错误码(与 i18n key `errors.*` 对应)。 * 由 actions 层根据 code 通过 getTranslations 翻译为用户可见文案。 @@ -45,94 +55,12 @@ export class ElectiveBusinessError extends BusinessError { } } -/** - * 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。 - */ -export function buildLotteryRankCase(ids: string[], startRank: number): SQL { - const branches = ids.map( - (id, idx) => sql`WHEN ${id} THEN ${startRank + idx}` - ) - return sql`CASE ${courseSelections.id} ${sql.join(branches, sql` `)} END` -} - -/** - * 星期字符串归一化映射(纯函数,便于测试)。 - * 支持中英文全称与缩写,统一映射为 1-7 数字字符串。 - */ -const DAY_NORMALIZE_MAP: Readonly> = Object.freeze({ - // 中文 - "周一": "1", "周二": "2", "周三": "3", "周四": "4", - "周五": "5", "周六": "6", "周日": "7", "周天": "7", - "星期一": "1", "星期二": "2", "星期三": "3", "星期四": "4", - "星期五": "5", "星期六": "6", "星期日": "7", "星期天": "7", - // 英文全称 - monday: "1", tuesday: "2", wednesday: "3", thursday: "4", - friday: "5", saturday: "6", sunday: "7", - // 英文缩写 - mon: "1", tue: "2", wed: "3", thu: "4", - fri: "5", sat: "6", sun: "7", -}) - -export function normalizeDay(day: string): string { - return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day -} - -/** - * 解析课程 schedule 字段为可比较的时间段数组(纯函数,便于测试)。 - * - * 支持多时段(以逗号或分号分隔),例如: - * - "周一 14:00-15:30" - * - "Mon 14:00-15:30, Wed 16:00-17:30" - * - "周一 14:00-15:30;周三 16:00-17:30" - * - * 返回空数组表示无法解析(不参与冲突检测)。 - */ -export function parseSchedule( - schedule: string | null -): Array<{ day: string; start: string; end: string }> { - if (!schedule || schedule.length === 0) return [] - - // 按逗号、分号、中文分号拆分多个时段 - const segments = schedule.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean) - const result: Array<{ day: string; start: string; end: string }> = [] - - // 支持中英文星期与英文全称/缩写 - const dayPattern = "周[一二三四五六日天]|星期[一二三四五六日天]|[Mm]on(?:day)?|[Tt]ue(?:sday)?|[Ww]ed(?:nesday)?|[Tt]hu(?:rsday)?|[Ff]ri(?:day)?|[Ss]at(?:urday)?|[Ss]un(?:day)?" - const timePattern = "(\\d{1,2}):(\\d{2})" - const re = new RegExp( - `^(${dayPattern})\\s+${timePattern}\\s*[-~~至到]\\s*${timePattern}$`, - "i" - ) - - for (const seg of segments) { - const match = seg.match(re) - if (!match) continue - const [, day, startH, startM, endH, endM] = match - if (!day || !startH || !startM || !endH || !endM) continue - result.push({ - day, - start: `${startH.padStart(2, "0")}:${startM}`, - end: `${endH.padStart(2, "0")}:${endM}`, - }) - } - return result -} - -/** - * 检测两组时间段是否存在冲突(纯函数,便于测试)。 - * 仅当 day 相同(归一化后)且时间区间重叠时判定为冲突。 - */ -export function isScheduleConflict( - a: { day: string; start: string; end: string }, - b: { day: string; start: string; end: string } -): boolean { - if (normalizeDay(a.day) !== normalizeDay(b.day)) return false - return a.start < b.end && b.start < a.end -} - /** * 检测学生选课时间冲突(P2 建议:选课时间冲突检测)。 * 查询学生已选/已录取的课程,与新课程 schedule 比对。 + * + * A-02 修复:时间段解析与冲突判定纯函数已抽离至 lib/schedule-conflict.ts, + * 本函数仅保留 DB 查询(读取新课程 schedule + 学生已选课程 schedule)。 */ async function checkScheduleConflict( tx: Parameters[0]>[0], @@ -162,12 +90,8 @@ async function checkScheduleConflict( for (const row of existingCourses) { const existingSlots = parseSchedule(row.schedule) - for (const newSlot of newSlots) { - for (const existingSlot of existingSlots) { - if (isScheduleConflict(newSlot, existingSlot)) { - return true - } - } + if (hasAnyScheduleConflict(newSlots, existingSlots)) { + return true } } return false @@ -247,24 +171,10 @@ export async function runLottery(courseId: string): Promise<{ return { enrolled: 0, waitlist: 0 } } - // Fisher-Yates shuffle: 无偏均匀随机排列,避免 sort(() => Math.random() - 0.5) 的分布偏差 - const shuffled = [...selections] - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)) - ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] - } + // A-02 修复:洗牌 + 分桶算法已抽离至 lib/lottery.ts(partitionLottery) const capacity = course.capacity const now = new Date() - - const enrolledIds: string[] = [] - const waitlistIds: string[] = [] - for (let i = 0; i < shuffled.length; i++) { - if (i < capacity) { - enrolledIds.push(shuffled[i].id) - } else { - waitlistIds.push(shuffled[i].id) - } - } + const { enrolledIds, waitlistIds } = partitionLottery(selections, capacity) const enrolledCount = enrolledIds.length const waitlistCount = waitlistIds.length diff --git a/src/modules/elective/data-access-selections.ts b/src/modules/elective/data-access-selections.ts index 698c368..ab0b25c 100644 --- a/src/modules/elective/data-access-selections.ts +++ b/src/modules/elective/data-access-selections.ts @@ -8,6 +8,7 @@ import { electiveCourses, } from "@/shared/db/schema" import { cacheFn } from "@/shared/lib/cache" +import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils" import { buildCourseSelect, @@ -40,11 +41,6 @@ type SelectionCoreRow = { dropReason: string | null } -const toIso = (d: Date | null | undefined): string | null => - d ? d.toISOString() : null - -const toIsoRequired = (d: Date): string => d.toISOString() - const mapSelectionRow = ( r: SelectionCoreRow, studentNames: Map diff --git a/src/modules/elective/data-access.ts b/src/modules/elective/data-access.ts index eb9e775..f18a7a9 100644 --- a/src/modules/elective/data-access.ts +++ b/src/modules/elective/data-access.ts @@ -7,6 +7,7 @@ import { db } from "@/shared/db" import { electiveCourses } from "@/shared/db/schema" import type { DataScope } from "@/shared/types/permissions" import { safeParseDate } from "@/shared/lib/action-utils" +import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils" import { cacheFn } from "@/shared/lib/cache" import type { @@ -19,11 +20,6 @@ import type { } from "./schema" import { getCourseDisplayResolver } from "./resolvers" -const toIso = (d: Date | null | undefined): string | null => - d ? d.toISOString() : null - -const toIsoRequired = (d: Date): string => d.toISOString() - const buildScopeFilter = (scope: DataScope, userId?: string): SQL | null => { if (scope.type === "all") return null if (scope.type === "owned" && userId) return eq(electiveCourses.teacherId, userId) diff --git a/src/modules/error-book/components/add-error-book-dialog.tsx b/src/modules/error-book/components/add-error-book-dialog.tsx index 4bc9719..044482a 100644 --- a/src/modules/error-book/components/add-error-book-dialog.tsx +++ b/src/modules/error-book/components/add-error-book-dialog.tsx @@ -2,7 +2,7 @@ import { useState, useTransition, useEffect } from "react" import { Plus } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { @@ -71,7 +71,7 @@ export function AddErrorBookDialog({ function handleSubmit() { if (!questionId) { - toast.error(t("messages.selectQuestion")) + notify.error(t("messages.selectQuestion")) return } startTransition(async () => { @@ -82,13 +82,13 @@ export function AddErrorBookDialog({ ) const res = await createErrorBookItemAction(undefined, formData) if (res.success) { - toast.success(res.message ?? t("messages.addedShort")) + notify.success(res.message ?? t("messages.addedShort")) setOpen(false) setQuestionId("") setNote("") setErrorTags([]) } else { - toast.error(res.message ?? t("messages.addFailed")) + notify.error(res.message ?? t("messages.addFailed")) } }) } diff --git a/src/modules/error-book/components/error-book-detail-dialog.tsx b/src/modules/error-book/components/error-book-detail-dialog.tsx index 5ab94a8..d846e06 100644 --- a/src/modules/error-book/components/error-book-detail-dialog.tsx +++ b/src/modules/error-book/components/error-book-detail-dialog.tsx @@ -3,7 +3,7 @@ import { useState, useTransition } from "react" import { useTranslations } from "next-intl" import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Dialog, @@ -71,9 +71,9 @@ export function ErrorBookDetailDialog({ ) const res = await updateErrorBookNoteAction(undefined, formData) if (res.success) { - toast.success(t("messages.noteSaved")) + notify.success(t("messages.noteSaved")) } else { - toast.error(res.message ?? t("messages.saveFailed")) + notify.error(res.message ?? t("messages.saveFailed")) } }) } @@ -84,10 +84,10 @@ export function ErrorBookDetailDialog({ formData.append("itemId", item.id) const res = await archiveErrorBookItemAction(undefined, formData) if (res.success) { - toast.success(t("messages.archived")) + notify.success(t("messages.archived")) setOpen(false) } else { - toast.error(res.message ?? t("messages.archiveFailed")) + notify.error(res.message ?? t("messages.archiveFailed")) } }) } @@ -98,10 +98,10 @@ export function ErrorBookDetailDialog({ formData.append("itemId", item.id) const res = await deleteErrorBookItemAction(undefined, formData) if (res.success) { - toast.success(t("messages.deleted")) + notify.success(t("messages.deleted")) setOpen(false) } else { - toast.error(res.message ?? t("messages.deleteFailed")) + notify.error(res.message ?? t("messages.deleteFailed")) } }) } diff --git a/src/modules/error-book/components/review-buttons.tsx b/src/modules/error-book/components/review-buttons.tsx index 5d10fbb..d3cf479 100644 --- a/src/modules/error-book/components/review-buttons.tsx +++ b/src/modules/error-book/components/review-buttons.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from "react" import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" @@ -65,10 +65,10 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) { formData.append("json", JSON.stringify({ itemId, result })) const res = await reviewErrorBookItemAction(undefined, formData) if (res.success) { - toast.success(res.message ?? t("messages.reviewRecorded")) + notify.success(res.message ?? t("messages.reviewRecorded")) onReviewed?.() } else { - toast.error(res.message ?? t("messages.recordFailed")) + notify.error(res.message ?? t("messages.recordFailed")) setSelected(null) } })