From b333cda8c5a48de41ded521f80dc90ef21895c0d Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:17:24 +0800 Subject: [PATCH] refactor(data-access,lib): split data-access files and add type-guards across modules - grades: split analytics into class/overview/student/trend files - messaging: split into bulk/core/group/reports/templates - school: split into classrooms/departments/grades/schools/semesters/subjects - Add lib/type-guards.ts for attendance, classes, elective, exams, invitation-codes, leave-requests, notifications, scheduling, school, settings, standards, textbooks - Add lesson-preparation/lib/status-mappers.ts - Add parent/actions.ts - Add shared/lib/date-utils.ts --- src/modules/attendance/lib/type-guards.ts | 17 + .../classes/lib/admin-class-mappers.ts | 143 +++++ src/modules/elective/lib/lottery.ts | 73 +++ src/modules/elective/lib/schedule-conflict.ts | 101 ++++ src/modules/exams/lib/type-guards.ts | 80 +++ .../grades/data-access-analytics-class.ts | 389 ++++++++++++++ .../grades/data-access-analytics-overview.ts | 135 +++++ .../grades/data-access-analytics-student.ts | 261 +++++++++ .../grades/data-access-analytics-trend.ts | 134 +++++ .../invitation-codes/lib/type-guards.ts | 18 + src/modules/leave-requests/lib/type-guards.ts | 36 ++ .../lesson-preparation/lib/status-mappers.ts | 31 ++ src/modules/messaging/data-access-bulk.ts | 81 +++ src/modules/messaging/data-access-core.ts | 505 ++++++++++++++++++ src/modules/messaging/data-access-group.ts | 68 +++ src/modules/messaging/data-access-reports.ts | 280 ++++++++++ .../messaging/data-access-templates.ts | 241 +++++++++ src/modules/notifications/lib/type-guards.ts | 12 + src/modules/parent/actions.ts | 97 ++++ .../scheduling/lib/schedule-validation.ts | 158 ++++++ src/modules/school/data-access-classrooms.ts | 41 ++ src/modules/school/data-access-departments.ts | 64 +++ src/modules/school/data-access-grades.ts | 453 ++++++++++++++++ src/modules/school/data-access-schools.ts | 160 ++++++ src/modules/school/data-access-semesters.ts | 85 +++ src/modules/school/data-access-subjects.ts | 97 ++++ src/modules/school/lib/school-permissions.ts | 156 ++++++ src/modules/settings/lib/type-guards.ts | 26 + src/modules/standards/lib/type-guards.ts | 19 + src/modules/textbooks/lib/type-guards.ts | 122 +++++ src/shared/lib/date-utils.ts | 42 ++ 31 files changed, 4125 insertions(+) create mode 100644 src/modules/attendance/lib/type-guards.ts create mode 100644 src/modules/classes/lib/admin-class-mappers.ts create mode 100644 src/modules/elective/lib/lottery.ts create mode 100644 src/modules/elective/lib/schedule-conflict.ts create mode 100644 src/modules/exams/lib/type-guards.ts create mode 100644 src/modules/grades/data-access-analytics-class.ts create mode 100644 src/modules/grades/data-access-analytics-overview.ts create mode 100644 src/modules/grades/data-access-analytics-student.ts create mode 100644 src/modules/grades/data-access-analytics-trend.ts create mode 100644 src/modules/invitation-codes/lib/type-guards.ts create mode 100644 src/modules/leave-requests/lib/type-guards.ts create mode 100644 src/modules/lesson-preparation/lib/status-mappers.ts create mode 100644 src/modules/messaging/data-access-bulk.ts create mode 100644 src/modules/messaging/data-access-core.ts create mode 100644 src/modules/messaging/data-access-group.ts create mode 100644 src/modules/messaging/data-access-reports.ts create mode 100644 src/modules/messaging/data-access-templates.ts create mode 100644 src/modules/notifications/lib/type-guards.ts create mode 100644 src/modules/parent/actions.ts create mode 100644 src/modules/scheduling/lib/schedule-validation.ts create mode 100644 src/modules/school/data-access-classrooms.ts create mode 100644 src/modules/school/data-access-departments.ts create mode 100644 src/modules/school/data-access-grades.ts create mode 100644 src/modules/school/data-access-schools.ts create mode 100644 src/modules/school/data-access-semesters.ts create mode 100644 src/modules/school/data-access-subjects.ts create mode 100644 src/modules/school/lib/school-permissions.ts create mode 100644 src/modules/settings/lib/type-guards.ts create mode 100644 src/modules/standards/lib/type-guards.ts create mode 100644 src/modules/textbooks/lib/type-guards.ts create mode 100644 src/shared/lib/date-utils.ts diff --git a/src/modules/attendance/lib/type-guards.ts b/src/modules/attendance/lib/type-guards.ts new file mode 100644 index 0000000..b8abfab --- /dev/null +++ b/src/modules/attendance/lib/type-guards.ts @@ -0,0 +1,17 @@ +import type { AttendancePeriod } from "../types" + +const ATTENDANCE_PERIOD_VALUES = [ + "morning_reading", + "morning", + "afternoon", + "evening", + "full_day", +] as const + +/** 类型守卫:判断值是否为合法的 AttendancePeriod */ +export function isAttendancePeriod(value: unknown): value is AttendancePeriod { + return ( + typeof value === "string" && + (ATTENDANCE_PERIOD_VALUES as readonly string[]).includes(value) + ) +} diff --git a/src/modules/classes/lib/admin-class-mappers.ts b/src/modules/classes/lib/admin-class-mappers.ts new file mode 100644 index 0000000..7be40b5 --- /dev/null +++ b/src/modules/classes/lib/admin-class-mappers.ts @@ -0,0 +1,143 @@ +/** + * 班级模块共享辅助:科目类型守卫与 Admin 班级列表组装。 + * + * 集中提取自 `data-access-admin.ts` 中 `getAdminClassesRaw` 与 + * `getGradeManagedClassesRaw` 重复的 `subjectsByClassId` 构建与 + * `AdminClassListItem` 映射逻辑(G3-020 去重),以及 + * `data-access-admin.ts` / `data-access-teacher.ts` / `actions-shared.ts` + * 中重复的 `isClassSubject` / `toClassSubject`(G3-036 去重)。 + */ +import { DEFAULT_CLASS_SUBJECTS } from "../types" +import type { + AdminClassListItem, + ClassSubject, + ClassSubjectTeacherAssignment, + TeacherOption, +} from "../types" +import { compareClassLike } from "../data-access" + +// G3-029/G3-030 修复:以类型标注替代 as 断言,避免对元组类型做 widening 断言。 +// DEFAULT_CLASS_SUBJECTS 是 `as const` 元组,.includes() 需要 readonly string[] 入参, +// 显式标注的常量使其在不使用 as 的情况下满足 .includes() 签名。 +const CLASS_SUBJECTS_READONLY: readonly string[] = DEFAULT_CLASS_SUBJECTS + +/** + * 判断 unknown 是否为合法的 ClassSubject。 + * 统一替代 `data-access-admin.ts` / `data-access-teacher.ts` / + * `actions-shared.ts` 中重复定义的 `isClassSubject` 局部实现。 + * + * @param v - 任意值 + * @returns true 表示 v 是合法的 ClassSubject + */ +export function isClassSubject(v: unknown): v is ClassSubject { + return typeof v === "string" && CLASS_SUBJECTS_READONLY.includes(v) +} + +/** + * 将字符串安全窄化为 ClassSubject,非法值返回 null。 + * + * @param v - DB 中读取的科目名字符串 + * @returns 合法的 ClassSubject 或 null + */ +export function toClassSubject(v: string): ClassSubject | null { + return isClassSubject(v) ? v : null +} + +/** subjectRows 查询结果的行类型(classSubjectTeachers JOIN subjects/users) */ +export interface SubjectTeacherRow { + classId: string + subject: string + teacherId: string | null + teacherName: string | null + teacherEmail: string | null +} + +/** classRows 查询结果的行类型(classes JOIN users + studentCount 聚合) */ +export interface AdminClassRow { + id: string + schoolName: string | null + schoolId: string | null + name: string + grade: string + gradeId: string | null + homeroom: string | null + room: string | null + invitationCode: string | null + teacherId: string + teacherName: string | null + teacherEmail: string + studentCount: number | null + createdAt: Date + updatedAt: Date +} + +/** + * 将 subject teacher 行列表构建为 `Map>`。 + * 提取自 `getAdminClassesRaw` / `getGradeManagedClassesRaw` 中重复实现。 + * + * @param subjectRows - DB 查询返回的 subject teacher 行 + * @returns classId → (ClassSubject → 任课教师 or null) 映射 + */ +export function buildSubjectsByClassIdMap( + subjectRows: readonly SubjectTeacherRow[], +): Map> { + 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) + } + return subjectsByClassId +} + +/** + * 将 class 行 + subject teacher 映射组装为排序后的 `AdminClassListItem[]`。 + * 提取自 `getAdminClassesRaw` / `getGradeManagedClassesRaw` 中重复的 + * `list = rows.map(...)` + `list.sort(compareClassLike)` 逻辑。 + * + * @param classRows - DB 查询返回的 class 行 + * @param subjectsByClassId - 由 `buildSubjectsByClassIdMap` 构建的映射 + * @returns 排序后的 AdminClassListItem 列表 + */ +export function composeAdminClassList( + classRows: readonly AdminClassRow[], + subjectsByClassId: Map>, +): AdminClassListItem[] { + const list: AdminClassListItem[] = classRows.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 +} diff --git a/src/modules/elective/lib/lottery.ts b/src/modules/elective/lib/lottery.ts new file mode 100644 index 0000000..ea28b81 --- /dev/null +++ b/src/modules/elective/lib/lottery.ts @@ -0,0 +1,73 @@ +/** + * 抽签算法纯函数(A-02 修复:从 data-access-operations.ts 抽离)。 + * + * 设计原则: + * - 纯函数,无 DB 访问、无副作用,便于单元测试 + * - data-access 层查询 DB 获取 selections 与 course.capacity 后, + * 调用本模块函数完成洗牌与分桶;DB 写入仍由 data-access 层完成 + * - buildLotteryRankCase 构建 SQL 片段(不执行查询),供 data-access 层 update 使用 + */ + +import { sql, type SQL } from "drizzle-orm" + +import { courseSelections } from "@/shared/db/schema" + +/** + * 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。 + * + * 注意:本函数仅构建 SQL 片段对象,不执行任何 DB 查询,因此无需 server-only。 + * data-access 层在 update 语句中引用此片段完成批量 rank 赋值。 + */ +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` +} + +/** 抽签输入项:仅需 id 字段即可完成洗牌与分桶 */ +export interface LotterySelectable { + id: string +} + +/** + * Fisher-Yates 无偏洗牌:均匀随机排列,避免 sort(() => Math.random() - 0.5) 的分布偏差。 + * 返回新数组,不修改原数组。 + */ +export function fisherYatesShuffle(items: readonly T[]): T[] { + const shuffled = [...items] + 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]] + } + return shuffled +} + +/** + * 抽签分桶:将洗牌后的选课记录按容量分为 enrolled 与 waitlist 两组。 + * + * - 前 `capacity` 名:enrolled + * - 超出容量部分:waitlist + * + * 返回两组的 id 列表,供 data-access 层批量更新 status 与 lotteryRank。 + */ +export function partitionLottery( + selections: readonly T[], + capacity: number, +): { enrolledIds: string[]; waitlistIds: string[] } { + const shuffled = fisherYatesShuffle(selections) + + const enrolledIds: string[] = [] + const waitlistIds: string[] = [] + for (let i = 0; i < shuffled.length; i++) { + const item = shuffled[i] + if (!item) continue + if (i < capacity) { + enrolledIds.push(item.id) + } else { + waitlistIds.push(item.id) + } + } + + return { enrolledIds, waitlistIds } +} diff --git a/src/modules/elective/lib/schedule-conflict.ts b/src/modules/elective/lib/schedule-conflict.ts new file mode 100644 index 0000000..ad8edcc --- /dev/null +++ b/src/modules/elective/lib/schedule-conflict.ts @@ -0,0 +1,101 @@ +/** + * 选课时间冲突检测纯函数(A-02 修复:从 data-access-operations.ts 抽离)。 + * + * 设计原则: + * - 纯函数,无 DB 访问、无副作用,便于单元测试 + * - data-access 层查询 DB 获取 schedule 字符串后,调用本模块函数完成解析与冲突判定 + * - 支持中英文星期、多时段、多种分隔符 + */ + +/** + * 星期字符串归一化映射(纯函数,便于测试)。 + * 支持中英文全称与缩写,统一映射为 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 +} + +/** + * 检测新课程时间段与任意已有时间段是否存在冲突(纯函数)。 + * 任一配对冲突即返回 true。 + */ +export function hasAnyScheduleConflict( + newSlots: Array<{ day: string; start: string; end: string }>, + existingSlots: Array<{ day: string; start: string; end: string }>, +): boolean { + for (const newSlot of newSlots) { + for (const existingSlot of existingSlots) { + if (isScheduleConflict(newSlot, existingSlot)) { + return true + } + } + } + return false +} diff --git a/src/modules/exams/lib/type-guards.ts b/src/modules/exams/lib/type-guards.ts new file mode 100644 index 0000000..f8c1802 --- /dev/null +++ b/src/modules/exams/lib/type-guards.ts @@ -0,0 +1,80 @@ +/** + * exams 模块通用类型守卫集合。 + * + * 用于消除 `as Record` / `as { text?: string }` 等违规断言, + * 将 unknown 收窄逻辑集中到本文件,供 ai-pipeline、components、editor 等子目录复用。 + */ + +import type { JSONContent } from "@tiptap/react" + +// --- 通用 Record 守卫 --- + +/** 判断值是否为非 null 对象(即 Record)。消除 as Record 断言。 */ +export function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null +} + +// --- 题目内容相关守卫 --- + +/** 判断值是否为仅含 text 字段的简单对象。用于 question-bank-list 等仅需 text 字段的场景。 */ +export function isSimpleTextContent(v: unknown): v is { text?: string } { + return isRecord(v) && (v.text === undefined || typeof v.text === "string") +} + +/** + * 判断值是否为题目内容对象(含可选 text 与 options 字段)。 + * options 数组中每个元素仅校验 id/text 字段为可选字符串,isCorrect 等业务字段由下游处理。 + */ +export function isQuestionContentObj( + v: unknown +): v is { text?: string; options?: Array<{ id?: string; text?: string }> } { + if (!isRecord(v)) return false + if (v.text !== undefined && typeof v.text !== "string") return false + if (v.options !== undefined) { + if (!Array.isArray(v.options)) return false + for (const opt of v.options) { + if (!isRecord(opt)) return false + if (opt.id !== undefined && typeof opt.id !== "string") return false + if (opt.text !== undefined && typeof opt.text !== "string") return false + } + } + return true +} + +// --- JSONContent 守卫(Tiptap) --- + +/** 判断值是否为 Tiptap JSONContent 对象(必须含字符串 type 字段)。 */ +export function isJSONContent(v: unknown): v is JSONContent { + return isRecord(v) && typeof v.type === "string" +} + +/** 判断值是否为 Tiptap JSONContent 数组。 */ +export function isJSONContentArray(v: unknown): v is JSONContent[] { + if (!Array.isArray(v)) return false + return v.every(isJSONContent) +} + +// --- dnd-kit ID 转换辅助 --- + +/** 将 dnd-kit 的 UniqueIdentifier(string | number)安全转换为字符串。消除 as string 断言。 */ +export function toSortableId(id: string | number): string { + return typeof id === "string" ? id : String(id) +} + +// --- QuestionBlock 属性守卫 --- + +/** + * 判断值是否为 QuestionBlock 节点的 attrs 结构。 + * 用于 Tiptap NodeView 中从 node.attrs 安全收窄类型。 + * 返回类型用 string 而非 QuestionBlockType 联合,因为 NodeView 中 type 仅作为 select value 使用。 + */ +export function isQuestionBlockAttrs( + v: unknown +): v is { questionId: string; type: string; score: number } { + return ( + isRecord(v) && + typeof v.questionId === "string" && + typeof v.type === "string" && + typeof v.score === "number" + ) +} diff --git a/src/modules/grades/data-access-analytics-class.ts b/src/modules/grades/data-access-analytics-class.ts new file mode 100644 index 0000000..f0e8817 --- /dev/null +++ b/src/modules/grades/data-access-analytics-class.ts @@ -0,0 +1,389 @@ +import "server-only" + +/** + * 班级维度成绩分析数据访问层 + * + * G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分, + * 保持单一职责,所有函数签名向后兼容。 + * + * 职责: + * - getClassComparison: 班级对比(同年级各班的均分/及格率/优秀率) + * - getClassComparisonWithSignificance: 班级对比 + Cohen's d 显著性检验 + * - getSubjectComparison: 科目对比(同班级各科目雷达图数据) + * - getGradeDistribution: 班级分数分布(90-100/80-89/70-79/60-69/<60 各区间人数) + * - getGradeDistributionByGradeId: 年级整体 + 按班级拆分的成绩分布(年级仪表盘维度1) + */ + +import { cacheFn } from "@/shared/lib/cache" +import { and, eq, inArray } from "drizzle-orm" + +import { db } from "@/shared/db" +import { gradeRecords } from "@/shared/db/schema" +import { getClassesByGradeId } from "@/modules/classes/data-access" +import { getSubjectOptions } from "@/modules/school/data-access" +import type { DataScope } from "@/shared/types/permissions" + +import { normalize, toNumber } from "./lib/grade-utils" +import { buildScopeClassFilter } from "./lib/scope-filter" +import { + computeClassComparisonStats, + computeGradeDistribution, + computeGradeStats, + computeSignificance, + computeSubjectComparisonStats, +} from "./stats-service" +import type { + ClassComparisonItem, + ClassComparisonResult, + ClassComparisonSignificance, + GradeDistributionByGradeResult, + GradeDistributionResult, + SubjectComparisonItem, +} from "./types" + +export interface ClassComparisonParams { + gradeId: string + subjectId: string + examId?: string + semester?: "1" | "2" + scope: DataScope +} + +export const getClassComparisonRaw = async (params: ClassComparisonParams): Promise => { + const classRows = await getClassesByGradeId(params.gradeId) + + if (classRows.length === 0) return [] + + const scope = params.scope + const scopeClassIdSet = + scope.type === "class_taught" ? new Set(scope.classIds) : null + const allowedClassRows = scopeClassIdSet + ? classRows.filter((c) => scopeClassIdSet.has(c.id)) + : classRows + + if (allowedClassRows.length === 0) return [] + + const allowedClassIds = allowedClassRows.map((c) => c.id) + + // P3 修复:在 SQL where 中使用 inArray 过滤,并通过 buildScopeClassFilter 应用 scope 行级过滤 + const conditions = [ + inArray(gradeRecords.classId, allowedClassIds), + eq(gradeRecords.subjectId, params.subjectId), + ] + if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) + if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) + + const scopeFilter = buildScopeClassFilter(params.scope) + if (scopeFilter) conditions.push(scopeFilter) + + const allRows = await db + .select({ + classId: gradeRecords.classId, + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + studentId: gradeRecords.studentId, + }) + .from(gradeRecords) + .where(and(...conditions)) + + const byClass = new Map() + for (const r of allRows) { + const existing = byClass.get(r.classId) + if (existing) { + existing.push(r) + } else { + byClass.set(r.classId, [r]) + } + } + + const result: ClassComparisonItem[] = allowedClassRows.map((cls) => { + const rows = byClass.get(cls.id) ?? [] + const stats = computeClassComparisonStats(rows) + return { + classId: cls.id, + className: cls.name, + ...stats, + } + }) + + return result + } + +export const getClassComparison = cacheFn(getClassComparisonRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getClassComparison"], +}) + +/** + * P3-5: 获取班级对比数据 + 班级间统计显著性分析结果。 + * + * 显著性分析流程: + * 1. 复用 getClassComparison 获取各班级聚合 stats + * 2. 找到均分最高与最低班级 + * 3. 从 allRows 中提取这两个班级的归一化分数数组 + * 4. 调用 stats-service.computeSignificance 计算 Cohen's d 与 p 值 + * + * 返回的 significance 为 null 的场景: + * - 班级数 < 2 + * - 任意班级样本量 < 2 + * - 方差为 0(所有分数相同) + * + * 该函数复用 getClassComparison 的 cache(参数一致),不会触发额外 DB 查询。 + */ +export const getClassComparisonWithSignificanceRaw = async (params: ClassComparisonParams): Promise => { + const items = await getClassComparison(params) + + if (items.length < 2) { + return { items, significance: null } + } + + // 找出均分最高与最低班级 + let topClass = items[0] + let bottomClass = items[0] + for (const item of items) { + if (item.averageScore > topClass.averageScore) topClass = item + if (item.averageScore < bottomClass.averageScore) bottomClass = item + } + + if (topClass.classId === bottomClass.classId) { + return { items, significance: null } + } + + // 重新查询两个班级的原始分数(归一化 0-100),用于统计检验 + const conditions = [ + inArray(gradeRecords.classId, [topClass.classId, bottomClass.classId]), + eq(gradeRecords.subjectId, params.subjectId), + ] + if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) + if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) + + const scopeFilter = buildScopeClassFilter(params.scope) + if (scopeFilter) conditions.push(scopeFilter) + + const rawRows = await db + .select({ + classId: gradeRecords.classId, + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + }) + .from(gradeRecords) + .where(and(...conditions)) + + const topScores: number[] = [] + const bottomScores: number[] = [] + for (const r of rawRows) { + const normalized = normalize(toNumber(r.score), toNumber(r.fullScore)) + if (r.classId === topClass.classId) topScores.push(normalized) + else if (r.classId === bottomClass.classId) bottomScores.push(normalized) + } + + const sigResult = computeSignificance(topScores, bottomScores) + if (!sigResult) { + return { items, significance: null } + } + + const significance: ClassComparisonSignificance = { + topClassId: topClass.classId, + bottomClassId: bottomClass.classId, + cohensD: sigResult.cohensD, + effectSizeLabel: sigResult.effectSizeLabel, + pValue: sigResult.pValue, + isSignificant: sigResult.isSignificant, + } + + return { items, significance } + } + +export const getClassComparisonWithSignificance = cacheFn(getClassComparisonWithSignificanceRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getClassComparisonWithSignificance"], +}) + +export interface SubjectComparisonParams { + classId: string + examId?: string + semester?: "1" | "2" + scope: DataScope +} + +export const getSubjectComparisonRaw = async (params: SubjectComparisonParams): Promise => { + const scopeFilter = buildScopeClassFilter(params.scope) + const conditions = [eq(gradeRecords.classId, params.classId)] + if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) + if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) + if (scopeFilter) conditions.push(scopeFilter) + + const rows = await db + .select({ + subjectId: gradeRecords.subjectId, + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + }) + .from(gradeRecords) + .where(and(...conditions)) + + if (rows.length === 0) return [] + + // Fetch subject names via cross-module interface + const subjectOptions = await getSubjectOptions() + const subjectNameById = new Map() + for (const s of subjectOptions) subjectNameById.set(s.id, s.name) + + const bySubject = new Map() + + for (const r of rows) { + const sid = r.subjectId + if (!sid) continue + const entry = bySubject.get(sid) ?? { name: subjectNameById.get(sid) ?? "Unknown", scores: [] } + entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore))) + bySubject.set(sid, entry) + } + + const result: SubjectComparisonItem[] = [] + for (const [subjectId, entry] of bySubject.entries()) { + if (entry.scores.length === 0) continue + const stats = computeSubjectComparisonStats(entry.scores) + result.push({ + subjectId, + subjectName: entry.name, + ...stats, + }) + } + + return result.sort((a, b) => b.averageScore - a.averageScore) + } + +export const getSubjectComparison = cacheFn(getSubjectComparisonRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getSubjectComparison"], +}) + +export interface GradeDistributionParams { + classId: string + subjectId?: string + examId?: string + semester?: "1" | "2" + scope: DataScope + currentUserId?: string +} + +export const getGradeDistributionRaw = async (params: GradeDistributionParams): Promise => { + const conditions = [eq(gradeRecords.classId, params.classId)] + if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) + if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) + if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) + + if (params.scope.type === "class_members" && params.currentUserId) { + conditions.push(eq(gradeRecords.studentId, params.currentUserId)) + } + + const scopeFilter = buildScopeClassFilter(params.scope) + if (scopeFilter) conditions.push(scopeFilter) + + const rows = await db + .select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore }) + .from(gradeRecords) + .where(and(...conditions)) + + return computeGradeDistribution(rows) + } + +export const getGradeDistribution = cacheFn(getGradeDistributionRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getGradeDistribution"], +}) + +/** + * 年级仪表盘 - 维度1:获取年级整体 + 按班级拆分的成绩分布。 + * 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询成绩记录, + * 应用 buildScopeClassFilter 行级权限过滤,复用 computeGradeDistribution / computeGradeStats 纯函数。 + */ +export interface GradeDistributionByGradeParams { + gradeId: string + subjectId?: string + examId?: string + semester?: "1" | "2" + scope: DataScope +} + +export const getGradeDistributionByGradeIdRaw = async (params: GradeDistributionByGradeParams): Promise => { + const classRows = await getClassesByGradeId(params.gradeId) + + if (classRows.length === 0) { + return { + overall: computeGradeDistribution([]), + stats: null, + byClass: [], + } + } + + // scope 过滤:class_taught 仅保留当前教师所教班级 + const scope = params.scope + const scopeClassIdSet = + scope.type === "class_taught" ? new Set(scope.classIds) : null + const allowedClassRows = scopeClassIdSet + ? classRows.filter((c) => scopeClassIdSet.has(c.id)) + : classRows + + if (allowedClassRows.length === 0) { + return { + overall: computeGradeDistribution([]), + stats: null, + byClass: [], + } + } + + const allowedClassIds = allowedClassRows.map((c) => c.id) + + const conditions = [inArray(gradeRecords.classId, allowedClassIds)] + if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) + if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) + if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) + + const scopeFilter = buildScopeClassFilter(params.scope) + if (scopeFilter) conditions.push(scopeFilter) + + const rows = await db + .select({ + classId: gradeRecords.classId, + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + }) + .from(gradeRecords) + .where(and(...conditions)) + + const overall = computeGradeDistribution(rows) + const stats = computeGradeStats(rows) + + const byClassMap = new Map() + for (const r of rows) { + const existing = byClassMap.get(r.classId) + if (existing) { + existing.push(r) + } else { + byClassMap.set(r.classId, [r]) + } + } + + const byClass = allowedClassRows.map((cls) => { + const classRowsForClass = byClassMap.get(cls.id) ?? [] + return { + classId: cls.id, + className: cls.name, + distribution: computeGradeDistribution(classRowsForClass), + stats: computeGradeStats(classRowsForClass), + } + }) + + return { overall, stats, byClass } + } + +export const getGradeDistributionByGradeId = cacheFn(getGradeDistributionByGradeIdRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getGradeDistributionByGradeId"], +}) diff --git a/src/modules/grades/data-access-analytics-overview.ts b/src/modules/grades/data-access-analytics-overview.ts new file mode 100644 index 0000000..2925ab5 --- /dev/null +++ b/src/modules/grades/data-access-analytics-overview.ts @@ -0,0 +1,135 @@ +import "server-only" + +/** + * 全校汇总成绩分析数据访问层 + * + * G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分, + * 保持单一职责,所有函数签名向后兼容。 + * + * 职责: + * - getSchoolWideGradeSummary: 全校各年级成绩汇总(管理员视图,按年级聚合平均分/及格率/优秀率/学生数/班级数) + */ + +import { cacheFn } from "@/shared/lib/cache" +import { inArray } from "drizzle-orm" + +import { db } from "@/shared/db" +import { gradeRecords } from "@/shared/db/schema" +import { getClassesByGradeId } from "@/modules/classes/data-access" +import { getGrades } from "@/modules/school/data-access" +import type { DataScope } from "@/shared/types/permissions" + +import { computeGradeStats } from "./stats-service" +import type { + SchoolWideGradeSummary, + SchoolWideGradeSummaryItem, +} from "./types" + +/** + * v3-P2-9: 获取全校各年级成绩汇总(管理员视图)。 + * 按年级聚合平均分、及格率、优秀率、学生数、班级数。 + * 仅限管理员(scope.type === "all")调用。 + */ +export const getSchoolWideGradeSummaryRaw = async (scope: DataScope): Promise => { + // 管理员权限校验:仅 all scope 可调用 + if (scope.type !== "all") { + return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } } + } + + const allGrades = await getGrades() + if (allGrades.length === 0) { + return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } } + } + + // 并行查询每个年级的班级列表 + const gradeClassMaps = await Promise.all( + allGrades.map(async (g) => ({ + grade: g, + classList: await getClassesByGradeId(g.id), + })), + ) + + // 过滤掉无班级的年级 + const validGradeClassMaps = gradeClassMaps.filter((m) => m.classList.length > 0) + if (validGradeClassMaps.length === 0) { + return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } } + } + + // 并行查询每个年级的成绩记录(通过 classId inArray 过滤) + const gradeStatsResults = await Promise.all( + validGradeClassMaps.map(async (m) => { + const classIds = m.classList.map((c) => c.id) + const rows = await db + .select({ + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + studentId: gradeRecords.studentId, + }) + .from(gradeRecords) + .where(inArray(gradeRecords.classId, classIds)) + + const stats = computeGradeStats(rows) + const uniqueStudents = new Set(rows.map((r) => r.studentId)) + + return { + grade: m.grade, + classCount: m.classList.length, + studentCount: uniqueStudents.size, + recordCount: rows.length, + stats, + } + }), + ) + + const gradeItems: SchoolWideGradeSummaryItem[] = gradeStatsResults + .filter((r): r is typeof r & { stats: NonNullable } => r.stats !== null) + .map((r) => { + const stats = r.stats + return { + gradeId: r.grade.id, + gradeName: r.grade.name, + schoolName: r.grade.school.name, + classCount: r.classCount, + studentCount: r.studentCount, + recordCount: r.recordCount, + averageScore: stats.average, + passRate: stats.passRate, + excellentRate: stats.excellentRate, + } + }) + .sort((a, b) => a.schoolName.localeCompare(b.schoolName) || a.gradeName.localeCompare(b.gradeName)) + + // 计算全校汇总(按记录数加权平均) + const totalClassCount = gradeItems.reduce((sum, g) => sum + g.classCount, 0) + const totalStudentCount = gradeItems.reduce((sum, g) => sum + g.studentCount, 0) + const totalRecordCount = gradeItems.reduce((sum, g) => sum + g.recordCount, 0) + + const weightedAvg = totalRecordCount > 0 + ? Math.round((gradeItems.reduce((sum, g) => sum + g.averageScore * g.recordCount, 0) / totalRecordCount) * 100) / 100 + : 0 + const weightedPassRate = totalRecordCount > 0 + ? Math.round((gradeItems.reduce((sum, g) => sum + g.passRate * g.recordCount, 0) / totalRecordCount) * 100) / 100 + : 0 + const weightedExcellentRate = totalRecordCount > 0 + ? Math.round((gradeItems.reduce((sum, g) => sum + g.excellentRate * g.recordCount, 0) / totalRecordCount) * 100) / 100 + : 0 + + return { + grades: gradeItems, + totals: { + gradeCount: gradeItems.length, + classCount: totalClassCount, + studentCount: totalStudentCount, + recordCount: totalRecordCount, + averageScore: weightedAvg, + passRate: weightedPassRate, + excellentRate: weightedExcellentRate, + }, + } + } + +export const getSchoolWideGradeSummary = cacheFn(getSchoolWideGradeSummaryRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getSchoolWideGradeSummary"], +}) diff --git a/src/modules/grades/data-access-analytics-student.ts b/src/modules/grades/data-access-analytics-student.ts new file mode 100644 index 0000000..7579de3 --- /dev/null +++ b/src/modules/grades/data-access-analytics-student.ts @@ -0,0 +1,261 @@ +import "server-only" + +/** + * 学生维度成绩分析数据访问层 + * + * G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分, + * 保持单一职责,所有函数签名向后兼容。 + * + * 职责: + * - getStudentPositionInClassDistribution: 学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图) + * - getStudentGrowthArchive: 学生纵向成长档案(跨学年/学期聚合) + */ + +import { cacheFn } from "@/shared/lib/cache" +import { and, eq, isNotNull, ne } from "drizzle-orm" + +import { db } from "@/shared/db" +import { gradeRecords } from "@/shared/db/schema" +import { + getClassNameById, + getStudentActiveClassId, +} from "@/modules/classes/data-access" +import { getAcademicYears } from "@/modules/school/data-access" +import { getUserNamesByIds } from "@/modules/users/data-access" +import type { DataScope } from "@/shared/types/permissions" + +import { normalize, toNumber } from "./lib/grade-utils" +import { buildScopeClassFilter } from "./lib/scope-filter" +import { + buildGrowthArchivePoints, + computeAverageScore, + computeGradeDistribution, + findBucketIndex, +} from "./stats-service" +import type { + GradeDistributionWithPosition, + StudentGrowthArchiveResult, +} from "./types" + +/** + * P3-9: 获取学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。 + * + * 流程: + * 1. 通过 classes.getStudentActiveClassId 查询学生所在班级 + * 2. 查询该班级所有学生的成绩记录(不含学生 ID,仅聚合计数) + * 3. 查询该学生最近的归一化分数 + * 4. 调用 stats-service.findBucketIndex 计算学生所在桶索引 + * + * 返回 null 的场景: + * - 学生未分配到任何班级 + * - 班级无成绩记录 + * + * @param studentId - 当前学生 ID + * @param scope - 当前用户的数据范围(学生为 class_members) + */ +export const getStudentPositionInClassDistributionRaw = async ( + studentId: string, + scope: DataScope + ): Promise => { + // 仅学生(class_members scope)调用,且必须传 studentId + if (scope.type !== "class_members") return null + + const classId = await getStudentActiveClassId(studentId) + if (!classId) return null + + const className = (await getClassNameById(classId)) ?? "" + + // 查询该班级全部学生的成绩记录(聚合计数,不含个人身份) + const conditions = [eq(gradeRecords.classId, classId)] + const scopeFilter = buildScopeClassFilter(scope) + if (scopeFilter) conditions.push(scopeFilter) + + const allRows = await db + .select({ + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + studentId: gradeRecords.studentId, + createdAt: gradeRecords.createdAt, + }) + .from(gradeRecords) + .where(and(...conditions)) + + if (allRows.length === 0) return null + + const distribution = computeGradeDistribution( + allRows.map((r) => ({ score: r.score, fullScore: r.fullScore })) + ) + + // 获取该学生最近一条成绩记录的归一化分数 + const studentRows = allRows + .filter((r) => r.studentId === studentId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + + if (studentRows.length === 0) { + return { + distribution, + classId, + className, + studentNormalizedScore: null, + studentBucketIndex: -1, + } + } + + const latestRow = studentRows[0] + const studentNormalizedScore = normalize( + toNumber(latestRow.score), + toNumber(latestRow.fullScore) + ) + const bucketIndex = findBucketIndex(distribution.buckets, studentNormalizedScore) + + return { + distribution, + classId, + className, + studentNormalizedScore, + studentBucketIndex: bucketIndex, + } + } + +export const getStudentPositionInClassDistribution = cacheFn(getStudentPositionInClassDistributionRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getStudentPositionInClassDistribution"], +}) + +/** + * P3-4: 获取学生纵向成长档案(跨学年/学期聚合)。 + * + * 流程: + * 1. 校验 class_taught scope(学生必须属于教师所教班级) + * 2. 获取学生姓名 + * 3. 获取所有学年信息(id → { name, startDate }) + * 4. 查询学生的全部成绩记录(含 academicYearId) + * 5. 调用 stats-service.buildGrowthArchivePoints 计算成长点序列 + * 6. 计算总览统计(overallAverage / totalRecords / totalSubjects / totalAcademicYears / growthDelta) + * + * 返回 null 的场景: + * - 学生不存在 + * - 学生无任何成绩记录 + * + * 注:class_members / children scope 的越权校验由 action 层完成(需 ctx.userId) + * + * @param studentId - 学生 ID + * @param scope - 当前用户的数据范围 + */ +export const getStudentGrowthArchiveRaw = async ( + studentId: string, + scope: DataScope + ): Promise => { + // scope 校验:仅 class_taught 需在 data-access 层校验(其余由 action 层处理) + if (scope.type === "class_taught") { + const studentClassId = await getStudentActiveClassId(studentId) + if (studentClassId && !scope.classIds.includes(studentClassId)) { + return null + } + } + + // 获取学生姓名 + const studentNameMap = await getUserNamesByIds([studentId]) + const studentName = studentNameMap.get(studentId)?.name ?? null + if (!studentName && !studentNameMap.has(studentId)) return null + + // 获取所有学年信息 + const academicYears = await getAcademicYears() + const academicYearMap = new Map() + for (const y of academicYears) { + academicYearMap.set(y.id, { + name: y.name, + startDate: new Date(y.startDate), + }) + } + + // 查询学生的全部成绩记录(含 academicYearId 非空过滤) + const conditions = [ + eq(gradeRecords.studentId, studentId), + isNotNull(gradeRecords.academicYearId), + ne(gradeRecords.academicYearId, ""), + ] + + const scopeFilter = buildScopeClassFilter(scope, studentId) + if (scopeFilter) conditions.push(scopeFilter) + + const rows = await db + .select({ + academicYearId: gradeRecords.academicYearId, + semester: gradeRecords.semester, + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + subjectId: gradeRecords.subjectId, + title: gradeRecords.title, + }) + .from(gradeRecords) + .where(and(...conditions)) + + if (rows.length === 0) { + return null + } + + // 构建原始数据行(合并学年信息) + const archiveRows = rows + .map((r) => { + const yearId = r.academicYearId + if (!yearId) return null + const yearInfo = academicYearMap.get(yearId) + if (!yearInfo) return null + return { + academicYearId: yearId, + academicYearName: yearInfo.name, + academicYearStart: yearInfo.startDate, + semester: r.semester as "1" | "2", + score: r.score, + fullScore: r.fullScore, + subjectId: r.subjectId, + title: r.title, + } + }) + .filter((r): r is NonNullable => r !== null) + + if (archiveRows.length === 0) { + return null + } + + // 调用 stats-service 纯函数构建成长点序列 + const points = buildGrowthArchivePoints(archiveRows) + if (points.length === 0) { + return null + } + + // 计算总览统计 + const allNormalizedScores = rows + .map((r) => normalize(toNumber(r.score), toNumber(r.fullScore))) + const overallAverage = computeAverageScore(allNormalizedScores) + const totalSubjects = new Set(rows.map((r) => r.subjectId)).size + const totalAcademicYears = new Set( + archiveRows.map((r) => r.academicYearId) + ).size + + // 成长趋势:最新点平均分 - 第一个点平均分 + const firstPoint = points[0] + const lastPoint = points[points.length - 1] + const growthDelta = Math.round( + (lastPoint.averageScore - firstPoint.averageScore) * 100 + ) / 100 + + return { + studentId, + studentName: studentName ?? "Unknown", + points, + overallAverage, + totalRecords: rows.length, + totalSubjects, + totalAcademicYears, + growthDelta, + } + } + +export const getStudentGrowthArchive = cacheFn(getStudentGrowthArchiveRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getStudentGrowthArchive"], +}) diff --git a/src/modules/grades/data-access-analytics-trend.ts b/src/modules/grades/data-access-analytics-trend.ts new file mode 100644 index 0000000..5a1ea97 --- /dev/null +++ b/src/modules/grades/data-access-analytics-trend.ts @@ -0,0 +1,134 @@ +import "server-only" + +/** + * 成绩趋势分析数据访问层 + * + * G2-005 / S-01 治理(2026-07-07):从 data-access-analytics.ts 拆分, + * 保持单一职责,所有函数签名向后兼容。 + * + * 职责: + * - getGradeTrend: 成绩趋势(按学生/科目/学期,返回归一化分数趋势点) + * - getExamOptionsForGrades: 获取指定班级/科目下有成绩记录的考试列表(分析页考试筛选) + */ + +import { cacheFn } from "@/shared/lib/cache" +import { and, asc, eq, isNotNull, ne } from "drizzle-orm" + +import { db } from "@/shared/db" +import { gradeRecords } from "@/shared/db/schema" +import { getClassNameById } from "@/modules/classes/data-access" +import { getSubjectOptions } from "@/modules/school/data-access" +import type { DataScope } from "@/shared/types/permissions" + +import { buildScopeClassFilter } from "./lib/scope-filter" +import { buildGradeTrendPoints, computeTrendAverage } from "./stats-service" +import type { GradeTrendResult } from "./types" + +export interface GradeTrendParams { + classId: string + subjectId?: string + studentId?: string + semester?: "1" | "2" + examId?: string + scope: DataScope + currentUserId?: string +} + +export const getGradeTrendRaw = async (params: GradeTrendParams): Promise => { + const conditions = [eq(gradeRecords.classId, params.classId)] + if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) + if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId)) + if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) + if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) + + if (params.scope.type === "class_members" && params.currentUserId) { + conditions.push(eq(gradeRecords.studentId, params.currentUserId)) + } + + const scopeFilter = buildScopeClassFilter(params.scope) + if (scopeFilter) conditions.push(scopeFilter) + + const rows = await db + .select({ + record: gradeRecords, + }) + .from(gradeRecords) + .where(and(...conditions)) + .orderBy(asc(gradeRecords.createdAt)) + + if (rows.length === 0) return null + + // Fetch display names via cross-module interfaces + const className = await getClassNameById(params.classId) + let subjectName = "All Subjects" + if (params.subjectId) { + const subjectOptions = await getSubjectOptions() + const subject = subjectOptions.find((s) => s.id === params.subjectId) + subjectName = subject?.name ?? "Unknown" + } + + const points = buildGradeTrendPoints(rows) + const avg = computeTrendAverage(points) + const finalClassName = className ?? "Class" + const studentLabel = params.studentId + ? `Student ${params.studentId.slice(-4)}` + : "Class Average" + + return { + label: params.subjectId + ? `${finalClassName} · ${subjectName} · ${studentLabel}` + : `${finalClassName} · ${studentLabel}`, + points, + averageScore: avg, + } + } + +export const getGradeTrend = cacheFn(getGradeTrendRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getGradeTrend"], +}) + +/** + * v3-P2-7: 获取指定班级/科目下有成绩记录的考试列表,用于分析页考试筛选下拉框。 + * 仅返回 examId 非空且去重后的考试选项。 + */ +export const getExamOptionsForGradesRaw = async (params: { + classId: string + subjectId?: string + scope: DataScope + }): Promise> => { + const conditions = [ + eq(gradeRecords.classId, params.classId), + isNotNull(gradeRecords.examId), + ne(gradeRecords.examId, ""), + ] + if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) + + const scopeFilter = buildScopeClassFilter(params.scope) + if (scopeFilter) conditions.push(scopeFilter) + + const rows = await db + .select({ + examId: gradeRecords.examId, + title: gradeRecords.title, + }) + .from(gradeRecords) + .where(and(...conditions)) + + // 去重:同一 examId 可能有多条成绩记录,保留第一条的 title + const seen = new Set() + const result: Array<{ id: string; title: string }> = [] + for (const r of rows) { + if (!r.examId || seen.has(r.examId)) continue + seen.add(r.examId) + result.push({ id: r.examId, title: r.title }) + } + return result + } + +export const getExamOptionsForGrades = cacheFn(getExamOptionsForGradesRaw, { + tags: ["grades"], + ttl: 300, + keyParts: ["grades", "getExamOptionsForGrades"], +}) diff --git a/src/modules/invitation-codes/lib/type-guards.ts b/src/modules/invitation-codes/lib/type-guards.ts new file mode 100644 index 0000000..42c73d6 --- /dev/null +++ b/src/modules/invitation-codes/lib/type-guards.ts @@ -0,0 +1,18 @@ +import type { InvitationRole } from "../types" +import { INVITATION_ROLE_VALUES } from "../schema" + +/** 类型守卫:判断值是否为合法的 InvitationRole */ +export function isInvitationRole(value: unknown): value is InvitationRole { + return ( + typeof value === "string" && + (INVITATION_ROLE_VALUES as readonly string[]).includes(value) + ) +} + +/** 安全转换:将未知值转为 InvitationRole,非法值回退到 fallback */ +export function toInvitationRole( + value: unknown, + fallback: InvitationRole = "student", +): InvitationRole { + return isInvitationRole(value) ? value : fallback +} diff --git a/src/modules/leave-requests/lib/type-guards.ts b/src/modules/leave-requests/lib/type-guards.ts new file mode 100644 index 0000000..05b10b9 --- /dev/null +++ b/src/modules/leave-requests/lib/type-guards.ts @@ -0,0 +1,36 @@ +import type { LeaveStatus, LeaveType } from "../types" + +const LEAVE_TYPE_VALUES = ["sick", "personal", "family", "other"] as const +const LEAVE_STATUS_VALUES = ["pending", "approved", "rejected", "cancelled"] as const + +/** 类型守卫:判断值是否为合法的 LeaveType */ +export function isLeaveType(value: unknown): value is LeaveType { + return ( + typeof value === "string" && + (LEAVE_TYPE_VALUES as readonly string[]).includes(value) + ) +} + +/** 安全转换:将未知值转为 LeaveType,非法值回退到 fallback */ +export function toLeaveType( + value: unknown, + fallback: LeaveType = "other", +): LeaveType { + return isLeaveType(value) ? value : fallback +} + +/** 类型守卫:判断值是否为合法的 LeaveStatus */ +export function isLeaveStatus(value: unknown): value is LeaveStatus { + return ( + typeof value === "string" && + (LEAVE_STATUS_VALUES as readonly string[]).includes(value) + ) +} + +/** 安全转换:将未知值转为 LeaveStatus,非法值回退到 fallback */ +export function toLeaveStatus( + value: unknown, + fallback: LeaveStatus = "pending", +): LeaveStatus { + return isLeaveStatus(value) ? value : fallback +} diff --git a/src/modules/lesson-preparation/lib/status-mappers.ts b/src/modules/lesson-preparation/lib/status-mappers.ts new file mode 100644 index 0000000..afbc4a8 --- /dev/null +++ b/src/modules/lesson-preparation/lib/status-mappers.ts @@ -0,0 +1,31 @@ +/** + * 备课模块状态映射辅助:将 DB 字符串字段安全窄化为联合类型。 + * + * 集中提取自 `data-access-review.ts` 与 `data-access-calendar.ts` 中重复的 + * `toLessonPlanStatus` / `toReviewDecision` 局部实现(G1-026 去重)。 + * 守卫失败时回退到默认值,替代 `as LessonPlanStatus` 断言。 + */ +import type { LessonPlanStatus } from "../types"; +import { isLessonPlanStatus, isReviewDecision, type ReviewDecision } from "./type-guards"; + +/** + * 将 DB enum 字符串安全窄化为 `LessonPlanStatus`。 + * 守卫失败时回退到 `"draft"`,替代 `as LessonPlanStatus` 断言。 + * + * @param value - DB 中读取的 status 字符串 + * @returns 合法的 LessonPlanStatus,非法值回退到 "draft" + */ +export function toLessonPlanStatus(value: string): LessonPlanStatus { + return isLessonPlanStatus(value) ? value : "draft"; +} + +/** + * 将 DB decision 字符串安全窄化为审核决策类型。 + * 守卫失败时回退到 `"rejected"`,替代 `as "approved" | "rejected"` 断言。 + * + * @param value - DB 中读取的 decision 字符串 + * @returns 合法的 ReviewDecision,非法值回退到 "rejected" + */ +export function toReviewDecision(value: string): ReviewDecision { + return isReviewDecision(value) ? value : "rejected"; +} diff --git a/src/modules/messaging/data-access-bulk.ts b/src/modules/messaging/data-access-bulk.ts new file mode 100644 index 0000000..7e99e57 --- /dev/null +++ b/src/modules/messaging/data-access-bulk.ts @@ -0,0 +1,81 @@ +import "server-only" + +/** + * 私信批量操作数据访问层 + * + * 审计 v1 P0 修复(G4-003 / S-01):从 data-access.ts 拆分。 + * + * 职责: + * - bulkMarkMessagesAsRead: 批量标记已读 + * - bulkDeleteMessages: 批量软删除 + * - bulkToggleMessagesStar: 批量切换星标 + */ + +import { and, eq, inArray } from "drizzle-orm" + +import { db } from "@/shared/db" +import { messages } from "@/shared/db/schema" + +// --------------------------------------------------------------------------- +// P2-1: 批量操作 +// --------------------------------------------------------------------------- + +/** P2-1: 批量标记已读(仅当前用户为接收方的消息) */ +export async function bulkMarkMessagesAsRead(ids: string[], userId: string): Promise { + if (ids.length === 0) return 0 + const result = await db + .update(messages) + .set({ isRead: true, readAt: new Date() }) + .where(and( + inArray(messages.id, ids), + eq(messages.receiverId, userId), + eq(messages.isRead, false), + )) + // MySqlRawQueryResult 是 [ResultSetHeader, FieldPacket[]] 元组,affectedRows 在 [0] + return result[0]?.affectedRows ?? 0 +} + +/** P2-1: 批量删除(软删除,发送方/接收方各自标记) */ +export async function bulkDeleteMessages(ids: string[], userId: string): Promise { + if (ids.length === 0) return + const now = new Date() + await db.transaction(async (tx) => { + await tx + .update(messages) + .set({ senderDeletedAt: now }) + .where(and(inArray(messages.id, ids), eq(messages.senderId, userId))) + await tx + .update(messages) + .set({ receiverDeletedAt: now }) + .where(and(inArray(messages.id, ids), eq(messages.receiverId, userId))) + }) +} + +/** P2-1: 批量切换星标(仅当前用户为接收方的消息) */ +export async function bulkToggleMessagesStar(ids: string[], userId: string): Promise { + if (ids.length === 0) return + // 查询当前状态 + const rows = await db + .select({ id: messages.id, isStarred: messages.isStarred }) + .from(messages) + .where(and(inArray(messages.id, ids), eq(messages.receiverId, userId))) + + if (rows.length === 0) return + + // 按目标状态分组 + const toStar = rows.filter((r) => !r.isStarred).map((r) => r.id) + const toUnstar = rows.filter((r) => r.isStarred).map((r) => r.id) + + if (toStar.length > 0) { + await db + .update(messages) + .set({ isStarred: true }) + .where(and(inArray(messages.id, toStar), eq(messages.receiverId, userId))) + } + if (toUnstar.length > 0) { + await db + .update(messages) + .set({ isStarred: false }) + .where(and(inArray(messages.id, toUnstar), eq(messages.receiverId, userId))) + } +} diff --git a/src/modules/messaging/data-access-core.ts b/src/modules/messaging/data-access-core.ts new file mode 100644 index 0000000..8d1a7f6 --- /dev/null +++ b/src/modules/messaging/data-access-core.ts @@ -0,0 +1,505 @@ +import "server-only" + +/** + * 私信核心数据访问层(CRUD + 查询 + 收件人解析) + * + * 审计 v1 P0 修复(G4-003 / S-01):从 data-access.ts 拆分, + * 文件行数从 1089 降至 < 600,保持单一职责。 + * + * 职责: + * - getMessages / getMessageById / getMessageThread: 私信查询 + * - createMessage / markMessageAsRead / deleteMessage / toggleMessageStar: 私信 CRUD + * - recallMessage: 消息撤回 + * - getUnreadMessageCount: 未读私信计数 + * - getRecipients: 获取收件人列表(按 DataScope 过滤) + * - isReceiverAllowed: 校验收件人是否在 sender 的 DataScope 内(P0-1 安全二次校验) + * - getMessageDetailPageData: 消息详情页编排 + * + * 变更历史: + * - P0-2: getMessageThread 增加 userId 参数,做权限校验 + * - P0-3: getMessagesPageData 编排迁出(移至 messages/page.tsx 页面层) + */ + +import { createId } from "@paralleldrive/cuid2" +import { and, count, desc, eq, gte, inArray, isNull, like, lte, or, type SQL } from "drizzle-orm" + +import { db } from "@/shared/db" +import { messages, users } from "@/shared/db/schema" +import { + getClassesByGradeId, + getStudentIdsByClassIds, + getTeacherIdsByClassIds, + getStudentActiveClassId, +} from "@/modules/classes/data-access" +import { getUserNamesByIds } from "@/modules/users/data-access" +import { escapeLikePattern } from "@/shared/lib/action-utils" +import type { DataScope } from "@/shared/types/permissions" +import type { PaginatedResult } from "@/modules/notifications/types" +import { cacheFn } from "@/shared/lib/cache" +import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils" +import type { + Message, + GetMessagesParams, + CreateMessageInput, + RecipientOption, + RecipientRole, + RecipientResolverContext, + MessagingRoleConfig, +} from "./types" + +// --------------------------------------------------------------------------- +// 共享 helpers(resolveUserNames export 供 data-access-templates.ts 复用) +// toIso / toIsoRequired 已迁移至 @/shared/lib/date-utils(审计 v1 P0-6 / S-04) +// --------------------------------------------------------------------------- + +interface MessageRow { + id: string + senderId: string + receiverId: string + subject: string | null + content: string + isRead: boolean + isStarred: boolean + recalledAt: Date | null + readAt: Date | null + parentMessageId: string | null + groupMessageId: string | null + createdAt: Date +} + +export async function resolveUserNames(userIds: string[]): Promise> { + const uniqueIds = [...new Set(userIds)].filter(Boolean) + if (uniqueIds.length === 0) return new Map() + const rows = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(inArray(users.id, uniqueIds)) + return new Map(rows.map((r) => [r.id, r.name ?? r.id])) +} + +const mapMessage = (r: MessageRow, nameMap: Map): Message => ({ + id: r.id, + senderId: r.senderId, + senderName: nameMap.get(r.senderId) ?? null, + receiverId: r.receiverId, + receiverName: nameMap.get(r.receiverId) ?? null, + subject: r.subject, + content: r.content, + isRead: r.isRead, + isStarred: r.isStarred, + recalledAt: toIso(r.recalledAt), + readAt: toIso(r.readAt), + parentMessageId: r.parentMessageId, + groupMessageId: r.groupMessageId, + createdAt: toIsoRequired(r.createdAt), +}) + +// --------------------------------------------------------------------------- +// 私信查询 +// --------------------------------------------------------------------------- + +export const getMessagesRaw = async ( + params: GetMessagesParams, +): Promise> => { + const page = Math.max(1, params.page ?? 1) + const pageSize = Math.max(1, params.pageSize ?? 20) + const offset = (page - 1) * pageSize + + const conds: SQL[] = [] + if (params.type === "inbox") { + conds.push(eq(messages.receiverId, params.userId)) + conds.push(isNull(messages.receiverDeletedAt)) + } else if (params.type === "sent") { + conds.push(eq(messages.senderId, params.userId)) + conds.push(isNull(messages.senderDeletedAt)) + } else { + // all: 仅返回当前用户未删除的消息(发送方未删 或 接收方未删) + const cond = or( + and(eq(messages.receiverId, params.userId), isNull(messages.receiverDeletedAt)), + and(eq(messages.senderId, params.userId), isNull(messages.senderDeletedAt)), + ) + if (cond) conds.push(cond) + } + + // 关键词搜索(匹配 subject 或 content) + if (params.keyword && params.keyword.trim().length > 0) { + // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 + const kw = `${escapeLikePattern(params.keyword.trim())}%` + const kwCond = or(like(messages.subject, kw), like(messages.content, kw)) + if (kwCond) conds.push(kwCond) + } + + // V2-P2-13c: 仅返回星标消息 + if (params.starredOnly) { + conds.push(eq(messages.isStarred, true)) + } + + // P2-11: 按发件人 ID 筛选 + if (params.senderId) { + conds.push(eq(messages.senderId, params.senderId)) + } + + // P2-11: 按日期范围筛选 + if (params.dateFrom) { + const fromDate = new Date(params.dateFrom) + if (!isNaN(fromDate.getTime())) { + conds.push(gte(messages.createdAt, fromDate)) + } + } + if (params.dateTo) { + const toDate = new Date(params.dateTo) + if (!isNaN(toDate.getTime())) { + conds.push(lte(messages.createdAt, toDate)) + } + } + + // P2-11: 按已读状态筛选 + if (params.isRead === true) { + conds.push(eq(messages.isRead, true)) + } else if (params.isRead === false) { + conds.push(eq(messages.isRead, false)) + } + + const where = and(...conds) + const [rows, [totalRow]] = await Promise.all([ + db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset), + db.select({ value: count() }).from(messages).where(where), + ]) + + const userIds = rows.flatMap((r) => [r.senderId, r.receiverId]) + const nameMap = await resolveUserNames(userIds) + const total = Number(totalRow?.value ?? 0) + return { items: rows.map((r) => mapMessage(r, nameMap)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) } +} + +export const getMessages = cacheFn(getMessagesRaw, { + tags: ["messaging"], + ttl: 60, + keyParts: ["messaging", "getMessages"], +}) + +export const getMessageByIdRaw = async ( + id: string, + userId: string, +): Promise => { + const [row] = await db + .select() + .from(messages) + .where( + and( + eq(messages.id, id), + or( + and(eq(messages.senderId, userId), isNull(messages.senderDeletedAt)), + and(eq(messages.receiverId, userId), isNull(messages.receiverDeletedAt)), + ), + ), + ) + .limit(1) + if (!row) return null + const nameMap = await resolveUserNames([row.senderId, row.receiverId]) + return mapMessage(row, nameMap) +} + +export const getMessageById = cacheFn(getMessageByIdRaw, { + tags: ["messaging"], + ttl: 60, + keyParts: ["messaging", "getMessageById"], +}) + +export const getMessageThreadRaw = async ( + messageId: string, + userId: string, +): Promise => { + // P0-2: 校验当前用户对根消息有访问权(必须是发送方或接收方) + const [root] = await db + .select() + .from(messages) + .where( + and( + eq(messages.id, messageId), + or(eq(messages.senderId, userId), eq(messages.receiverId, userId)), + ), + ) + .limit(1) + if (!root) return [] + + const replies = await db + .select() + .from(messages) + .where(eq(messages.parentMessageId, messageId)) + .orderBy(desc(messages.createdAt)) + + const allRows = [root, ...replies] + const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId])) + return allRows.map((r) => mapMessage(r, nameMap)) +} + +export const getMessageThread = cacheFn(getMessageThreadRaw, { + tags: ["messaging"], + ttl: 60, + keyParts: ["messaging", "getMessageThread"], +}) + +// --------------------------------------------------------------------------- +// 私信 CRUD +// --------------------------------------------------------------------------- + +export async function createMessage(data: CreateMessageInput): Promise { + const id = createId() + await db.insert(messages).values({ + id, + senderId: data.senderId, + receiverId: data.receiverId, + subject: data.subject ?? null, + content: data.content, + parentMessageId: data.parentMessageId ?? null, + }) + return id +} + +export async function markMessageAsRead(id: string, userId: string): Promise { + await db + .update(messages) + .set({ isRead: true, readAt: new Date() }) + .where(and(eq(messages.id, id), eq(messages.receiverId, userId), eq(messages.isRead, false))) +} + +export async function deleteMessage(id: string, userId: string): Promise { + const now = new Date() + // 软删除:发送方删除设置 senderDeletedAt,接收方删除设置 receiverDeletedAt,互不影响 + // 使用事务保证两次 UPDATE 的原子性,避免部分失败导致数据不一致 + await db.transaction(async (tx) => { + await tx + .update(messages) + .set({ senderDeletedAt: now }) + .where(and(eq(messages.id, id), eq(messages.senderId, userId))) + await tx + .update(messages) + .set({ receiverDeletedAt: now }) + .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) + }) +} + +export async function toggleMessageStar(id: string, userId: string): Promise { + // 查询当前星标状态 + const [row] = await db + .select({ isStarred: messages.isStarred }) + .from(messages) + .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) + .limit(1) + + if (!row) return + + await db + .update(messages) + .set({ isStarred: !row.isStarred }) + .where(and(eq(messages.id, id), eq(messages.receiverId, userId))) +} + +// --------------------------------------------------------------------------- +// P2-4: 消息撤回 +// --------------------------------------------------------------------------- + +/** P2-4: 撤回时间窗口(毫秒),发送后 2 分钟内可撤回 */ +export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000 + +/** + * P2-4: 撤回消息。 + * + * 规则: + * - 仅发送方可以撤回自己的消息 + * - 必须在 MESSAGE_RECALL_WINDOW_MS 时间窗口内 + * - 已撤回的消息(recalledAt 非空)不能重复撤回 + * + * 返回值: + * - "ok": 撤回成功 + * - "not_found": 消息不存在或不属于当前用户 + * - "expired": 超过撤回时间窗口 + * - "already_recalled": 消息已被撤回 + */ +export async function recallMessage( + id: string, + userId: string, +): Promise<"ok" | "not_found" | "expired" | "already_recalled"> { + const [row] = await db + .select({ + id: messages.id, + senderId: messages.senderId, + recalledAt: messages.recalledAt, + createdAt: messages.createdAt, + }) + .from(messages) + .where(and(eq(messages.id, id), eq(messages.senderId, userId))) + .limit(1) + + if (!row) return "not_found" + if (row.recalledAt) return "already_recalled" + + const elapsed = Date.now() - row.createdAt.getTime() + if (elapsed > MESSAGE_RECALL_WINDOW_MS) return "expired" + + await db + .update(messages) + .set({ recalledAt: new Date() }) + .where(eq(messages.id, id)) + + return "ok" +} + +// --------------------------------------------------------------------------- +// 未读计数 +// --------------------------------------------------------------------------- + +export const getUnreadMessageCountRaw = async (userId: string): Promise => { + const [row] = await db + .select({ value: count() }) + .from(messages) + .where(and(eq(messages.receiverId, userId), eq(messages.isRead, false), isNull(messages.receiverDeletedAt))) + return Number(row?.value ?? 0) +} + +export const getUnreadMessageCount = cacheFn(getUnreadMessageCountRaw, { + tags: ["messaging"], + ttl: 60, + keyParts: ["messaging", "getUnreadMessageCount"], +}) + +// --------------------------------------------------------------------------- +// P2-7: 配置驱动的角色收件人解析器 +// +// 每个 DataScope.type 对应一个 RecipientResolver,将解析逻辑与主流程解耦。 +// 新增角色或调整 DataScope 时仅需在 RECIPIENT_RESOLVERS 中增/改配置项, +// 无需修改 getRecipients 主流程(开闭原则)。 +// --------------------------------------------------------------------------- + +const resolveAdminRecipients = async (ctx: RecipientResolverContext): Promise => { + const { userId } = ctx + const all = await db.select({ id: users.id, name: users.name, email: users.email }).from(users) + // P1-6: admin 角色分支补 role + return all + .filter((r) => r.id !== userId) + .map((r) => ({ ...r, name: r.name ?? r.email, role: "admin" as RecipientRole })) +} + +const resolveClassTaughtRecipients = async (ctx: RecipientResolverContext): Promise => { + const { userId, scope } = ctx + if (scope.type !== "class_taught" || scope.classIds.length === 0) return [] + // 通过 classes data-access 获取学生 ID,避免直接 JOIN classEnrollments 表 + const studentIds = await getStudentIdsByClassIds(scope.classIds) + const userMap = await getUserNamesByIds(studentIds) + return Array.from(userMap.values()) + .filter((u) => u.id !== userId) + .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole })) +} + +const resolveGradeManagedRecipients = async (ctx: RecipientResolverContext): Promise => { + const { userId, scope } = ctx + if (scope.type !== "grade_managed" || scope.gradeIds.length === 0) return [] + // 通过 classes data-access 获取年级下所有班级,再获取学生 ID, + // 避免直接 JOIN classes / classEnrollments 表 + const classLists = await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g))) + const classIds = classLists.flat().map((c) => c.id) + const studentIds = await getStudentIdsByClassIds(classIds) + const userMap = await getUserNamesByIds(studentIds) + return Array.from(userMap.values()) + .filter((u) => u.id !== userId) + .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole })) +} + +const resolveClassMembersRecipients = async (ctx: RecipientResolverContext): Promise => { + const { userId, scope } = ctx + if (scope.type !== "class_members" || scope.classIds.length === 0) return [] + // 学生可以给自己班级的任课教师/班主任发消息 + const teacherIds = await getTeacherIdsByClassIds(scope.classIds) + const userMap = await getUserNamesByIds(teacherIds) + return Array.from(userMap.values()) + .filter((u) => u.id !== userId) + .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole })) +} + +const resolveChildrenRecipients = async (ctx: RecipientResolverContext): Promise => { + const { userId, scope } = ctx + if (scope.type !== "children" || scope.childrenIds.length === 0) return [] + // 家长可以给孩子的班主任/任课教师发消息 + const classIds = await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id))) + const validClassIds = classIds.filter((id): id is string => id !== null) + const teacherIds = await getTeacherIdsByClassIds(validClassIds) + const userMap = await getUserNamesByIds(teacherIds) + return Array.from(userMap.values()) + .filter((u) => u.id !== userId) + .map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole })) +} + +/** P2-7: 收件人解析器注册表,按 DataScope.type 索引 */ +const RECIPIENT_RESOLVERS: Record = { + all: { scopeType: "all", resolve: resolveAdminRecipients }, + owned: { scopeType: "owned", resolve: async () => [] }, + class_taught: { scopeType: "class_taught", resolve: resolveClassTaughtRecipients }, + grade_managed: { scopeType: "grade_managed", resolve: resolveGradeManagedRecipients }, + class_members: { scopeType: "class_members", resolve: resolveClassMembersRecipients }, + children: { scopeType: "children", resolve: resolveChildrenRecipients }, +} + +/** + * P2-7: 获取收件人列表(配置驱动)。 + * + * 根据 DataScope.type 从 RECIPIENT_RESOLVERS 注册表查找对应解析器并调用。 + * 新增角色或 DataScope 类型仅需: + * 1. 实现新的 RecipientResolver + * 2. 在 RECIPIENT_RESOLVERS 中新增配置项 + * 无需修改本函数。 + */ +export const getRecipientsRaw = async ( + userId: string, + scope: DataScope, +): Promise => { + const config = RECIPIENT_RESOLVERS[scope.type] + if (!config) return [] + return config.resolve({ userId, scope }) +} + +export const getRecipients = cacheFn(getRecipientsRaw, { + tags: ["messaging"], + ttl: 60, + keyParts: ["messaging", "getRecipients"], +}) + +/** + * P0-1: 校验收件人是否在 sender 的 DataScope 允许范围内。 + * + * 在 sendMessageAction 中作为 Server Action 二次校验,防止绕过前端 Select + * 直接构造 FormData 提交任意 receiverId 的越权发送。 + * + * 实现复用 getRecipients 的结果,避免重复解析 DataScope。 + */ +export async function isReceiverAllowed( + userId: string, + receiverId: string, + scope: DataScope, +): Promise { + const recipients = await getRecipients(userId, scope) + return recipients.some((r) => r.id === receiverId) +} + +/** + * 消息详情页编排函数:获取消息详情,并自动标记为已读(若当前用户为接收方且未读)。 + * 使用 next/server 的 after() 实现非阻塞标记,避免阻塞页面渲染。 + * + * 注:消息首页编排(getMessagesPageData)已迁出至 messages/page.tsx 页面层, + * 由页面通过 Promise.all 调用 messaging.data-access 和 notifications.data-access + * 保持模块独立性(P0-3 修复,避免 data-access 层跨模块动态 import)。 + */ +export async function getMessageDetailPageData( + id: string, + userId: string, +): Promise { + const message = await getMessageById(id, userId) + if (!message) return null + + // 自动标记已读:仅当当前用户为接收方且消息未读时 + if (!message.isRead && message.receiverId === userId) { + await markMessageAsRead(id, userId) + } + + return message +} diff --git a/src/modules/messaging/data-access-group.ts b/src/modules/messaging/data-access-group.ts new file mode 100644 index 0000000..803400a --- /dev/null +++ b/src/modules/messaging/data-access-group.ts @@ -0,0 +1,68 @@ +import "server-only" + +/** + * 群组消息数据访问层(P2-2: 教师→全班家长,fan-out on write) + * + * 审计 v1 P0 修复(G4-003 / S-01):从 data-access.ts 拆分。 + */ + +import { createId } from "@paralleldrive/cuid2" + +import { db } from "@/shared/db" +import { messages } from "@/shared/db/schema" +import { getStudentIdsByClassIds } from "@/modules/classes/data-access" +import { getParentIdsByStudentIds } from "@/modules/parent/data-access" + +export interface SendGroupMessageInput { + senderId: string + classId: string + subject: string | null + content: string +} + +export interface SendGroupMessageResult { + groupMessageId: string + recipientIds: string[] +} + +/** + * P2-2: 群发消息给指定班级的所有家长。 + * + * 采用 fan-out on write 策略:为每个家长创建独立的 messages 行, + * 共享同一 groupMessageId 便于后续聚合查询(如"已发送群组消息"列表)。 + * + * 通过 classes data-access 获取学生 ID,再通过 parent data-access + * 获取家长 ID,避免直接 JOIN classEnrollments / parentStudentRelations 表。 + */ +export async function sendGroupMessage( + data: SendGroupMessageInput, +): Promise { + // 1. 获取班级所有学生 ID + const studentIds = await getStudentIdsByClassIds([data.classId]) + // 2. 获取这些学生的所有家长 ID(去重) + const parentIds = await getParentIdsByStudentIds(studentIds) + // 过滤掉发送者自己(避免教师给自己发消息) + const recipientIds = Array.from(new Set(parentIds)).filter((id) => id !== data.senderId) + + if (recipientIds.length === 0) { + return { groupMessageId: "", recipientIds: [] } + } + + // 3. 生成 groupMessageId 并批量插入 + const groupMessageId = createId() + const now = new Date() + const rows = recipientIds.map((receiverId) => ({ + id: createId(), + senderId: data.senderId, + receiverId, + subject: data.subject ?? null, + content: data.content, + parentMessageId: null, + groupMessageId, + createdAt: now, + })) + + await db.insert(messages).values(rows) + + return { groupMessageId, recipientIds } +} diff --git a/src/modules/messaging/data-access-reports.ts b/src/modules/messaging/data-access-reports.ts new file mode 100644 index 0000000..960ebec --- /dev/null +++ b/src/modules/messaging/data-access-reports.ts @@ -0,0 +1,280 @@ +import "server-only" + +/** + * 消息举报 + 用户屏蔽 + 附件数据访问层(P2-3 / P2-5) + * + * 审计 v1 P0 修复(G4-003 / S-01):从 data-access.ts 拆分。 + * + * 职责: + * - P2-5: 消息举报(reportMessage / hasUserReportedMessage / getMessageReports) + * - P2-5: 用户屏蔽(blockUser / unblockUser / isUserBlocked / isEitherUserBlocked / getBlockedUserIds / getUserBlocks) + * - P2-3: 消息附件(getMessageAttachments,复用 fileAttachments 表) + */ + +import { createId } from "@paralleldrive/cuid2" +import { and, desc, eq, or } from "drizzle-orm" + +import { db } from "@/shared/db" +import { messageReports, userBlocks } from "@/shared/db/schema" +import { getFileAttachmentsByTarget } from "@/modules/files/data-access" +import type { + MessageReport, + CreateMessageReportInput, + MessageReportReason, + UserBlock, + MessageAttachment, +} from "./types" + +// --------------------------------------------------------------------------- +// P2-5: 消息举报 +// --------------------------------------------------------------------------- + +/** + * P2-5: 举报消息。 + * + * 防重复:同一用户对同一消息只能举报一次。已举报则返回 "already_reported"。 + */ +export async function reportMessage( + data: CreateMessageReportInput, +): Promise<"ok" | "already_reported"> { + // 检查是否已举报过该消息 + const [existing] = await db + .select({ id: messageReports.id }) + .from(messageReports) + .where( + and( + eq(messageReports.reporterId, data.reporterId), + eq(messageReports.messageId, data.messageId), + ), + ) + .limit(1) + + if (existing) return "already_reported" + + const id = createId() + await db.insert(messageReports).values({ + id, + reporterId: data.reporterId, + messageId: data.messageId, + reportedUserId: data.reportedUserId, + reason: data.reason, + description: data.description ?? null, + }) + return "ok" +} + +/** + * P2-5: 检查用户是否已举报某条消息(用于 UI 按钮禁用状态)。 + */ +export async function hasUserReportedMessage( + reporterId: string, + messageId: string, +): Promise { + const [row] = await db + .select({ id: messageReports.id }) + .from(messageReports) + .where( + and( + eq(messageReports.reporterId, reporterId), + eq(messageReports.messageId, messageId), + ), + ) + .limit(1) + return !!row +} + +// --------------------------------------------------------------------------- +// P2-5: 用户屏蔽 +// --------------------------------------------------------------------------- + +/** + * P2-5: 屏蔽用户。 + * + * 利用 unique 索引防止重复屏蔽:插入冲突时视为"已屏蔽"。 + */ +export async function blockUser( + blockerId: string, + blockedId: string, +): Promise<"ok" | "already_blocked" | "self_block"> { + if (blockerId === blockedId) return "self_block" + + // 检查是否已屏蔽 + const [existing] = await db + .select({ id: userBlocks.id }) + .from(userBlocks) + .where( + and( + eq(userBlocks.blockerId, blockerId), + eq(userBlocks.blockedId, blockedId), + ), + ) + .limit(1) + + if (existing) return "already_blocked" + + const id = createId() + await db.insert(userBlocks).values({ id, blockerId, blockedId }) + return "ok" +} + +/** + * P2-5: 取消屏蔽用户。 + */ +export async function unblockUser( + blockerId: string, + blockedId: string, +): Promise { + await db + .delete(userBlocks) + .where( + and( + eq(userBlocks.blockerId, blockerId), + eq(userBlocks.blockedId, blockedId), + ), + ) +} + +/** + * P2-5: 检查 blocker 是否屏蔽了 blocked(用于发送消息前校验)。 + */ +export async function isUserBlocked( + blockerId: string, + blockedId: string, +): Promise { + const [row] = await db + .select({ id: userBlocks.id }) + .from(userBlocks) + .where( + and( + eq(userBlocks.blockerId, blockerId), + eq(userBlocks.blockedId, blockedId), + ), + ) + .limit(1) + return !!row +} + +/** + * P2-5: 判断两个用户之间是否存在屏蔽关系(任一方向)。 + * 用于 sendMessage 校验:发送方或接收方任一屏蔽对方,则禁止发送。 + */ +export async function isEitherUserBlocked( + userIdA: string, + userIdB: string, +): Promise { + const [row] = await db + .select({ id: userBlocks.id }) + .from(userBlocks) + .where( + or( + and(eq(userBlocks.blockerId, userIdA), eq(userBlocks.blockedId, userIdB)), + and(eq(userBlocks.blockerId, userIdB), eq(userBlocks.blockedId, userIdA)), + ), + ) + .limit(1) + return !!row +} + +/** + * P2-5: 获取用户屏蔽的所有用户 ID(用于收件人列表过滤)。 + */ +export async function getBlockedUserIds(blockerId: string): Promise { + const rows = await db + .select({ blockedId: userBlocks.blockedId }) + .from(userBlocks) + .where(eq(userBlocks.blockerId, blockerId)) + return rows.map((r) => r.blockedId) +} + +// --------------------------------------------------------------------------- +// P2-5: 举报列表查询(admin 管理用) +// --------------------------------------------------------------------------- + +/** P2-5: 映射举报行到接口类型 */ +const mapMessageReport = (r: { + id: string + reporterId: string + messageId: string + reportedUserId: string + reason: string + description: string | null + status: string + createdAt: Date + updatedAt: Date +}): MessageReport => ({ + id: r.id, + reporterId: r.reporterId, + messageId: r.messageId, + reportedUserId: r.reportedUserId, + reason: r.reason as MessageReportReason, + description: r.description, + status: r.status as MessageReport["status"], + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), +}) + +/** + * P2-5: 获取举报列表(admin 管理用,按状态筛选)。 + */ +export async function getMessageReports( + status?: MessageReport["status"], +): Promise { + const where = status ? eq(messageReports.status, status) : undefined + const query = db + .select() + .from(messageReports) + .orderBy(desc(messageReports.createdAt)) + const rows = where ? await query.where(where) : await query + return rows.map(mapMessageReport) +} + +/** P2-5: 映射屏蔽行到接口类型 */ +const mapUserBlock = (r: { + id: string + blockerId: string + blockedId: string + createdAt: Date +}): UserBlock => ({ + id: r.id, + blockerId: r.blockerId, + blockedId: r.blockedId, + createdAt: r.createdAt.toISOString(), +}) + +/** + * P2-5: 获取用户屏蔽列表。 + */ +export async function getUserBlocks(blockerId: string): Promise { + const rows = await db + .select() + .from(userBlocks) + .where(eq(userBlocks.blockerId, blockerId)) + .orderBy(desc(userBlocks.createdAt)) + return rows.map(mapUserBlock) +} + +// --------------------------------------------------------------------------- +// P2-3: 消息附件(复用 fileAttachments 表,targetType="message") +// --------------------------------------------------------------------------- + +/** P2-3: 附件 target 类型常量,用于 fileAttachments 表关联 */ +export const MESSAGE_ATTACHMENT_TARGET_TYPE = "message" + +/** + * P2-3: 获取指定消息的所有附件。 + * + * 复用 files 模块的 getFileAttachmentsByTarget,避免直接查询 fileAttachments 表。 + */ +export async function getMessageAttachments(messageId: string): Promise { + const files = await getFileAttachmentsByTarget(MESSAGE_ATTACHMENT_TARGET_TYPE, messageId) + return files.map((f) => ({ + id: f.id, + filename: f.filename, + originalName: f.originalName, + mimeType: f.mimeType, + size: f.size, + url: f.url, + // files 模块 FileAttachment.createdAt 已为 ISO 字符串 + createdAt: f.createdAt, + })) +} diff --git a/src/modules/messaging/data-access-templates.ts b/src/modules/messaging/data-access-templates.ts new file mode 100644 index 0000000..a77feae --- /dev/null +++ b/src/modules/messaging/data-access-templates.ts @@ -0,0 +1,241 @@ +import "server-only" + +/** + * 消息草稿 + 快捷回复模板数据访问层 + * + * 审计 v1 P0 修复(G4-003 / S-01):从 data-access.ts 拆分。 + * + * 职责: + * - V2-P2-13c: 消息草稿 CRUD(message_drafts 表),含 P2-10 乐观锁 + * - P2-6: 快捷回复模板 CRUD(message_templates 表) + */ + +import { createId } from "@paralleldrive/cuid2" +import { and, desc, eq } from "drizzle-orm" + +import { db } from "@/shared/db" +import { messageDrafts, messageTemplates } from "@/shared/db/schema" +import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils" +import { resolveUserNames } from "./data-access-core" +import type { + MessageDraft, + CreateMessageDraftInput, + UpdateMessageDraftInput, + MessageTemplate, + CreateMessageTemplateInput, + UpdateMessageTemplateInput, +} from "./types" + +// --------------------------------------------------------------------------- +// V2-P2-13c: 消息草稿 CRUD(message_drafts 表) +// --------------------------------------------------------------------------- + +const mapDraft = ( + r: { + id: string + userId: string + receiverId: string | null + subject: string | null + content: string | null + parentMessageId: string | null + deviceId: string | null + version: number + createdAt: Date + updatedAt: Date + }, + nameMap: Map, +): MessageDraft => ({ + id: r.id, + userId: r.userId, + receiverId: r.receiverId, + receiverName: r.receiverId ? (nameMap.get(r.receiverId) ?? null) : null, + subject: r.subject, + content: r.content, + parentMessageId: r.parentMessageId, + deviceId: r.deviceId, + version: r.version, + createdAt: toIsoRequired(r.createdAt), + updatedAt: toIsoRequired(r.updatedAt), +}) + +export const getMessageDraftsRaw = async ( + userId: string, +): Promise => { + const rows = await db + .select() + .from(messageDrafts) + .where(eq(messageDrafts.userId, userId)) + .orderBy(desc(messageDrafts.updatedAt)) + + const receiverIds = rows.map((r) => r.receiverId).filter((id): id is string => id !== null) + const nameMap = await resolveUserNames(receiverIds) + + return rows.map((r) => mapDraft(r, nameMap)) +} + +export const getMessageDrafts = cacheFn(getMessageDraftsRaw, { + tags: ["messaging"], + ttl: 60, + keyParts: ["messaging", "getMessageDrafts"], +}) + +export async function createMessageDraft(data: CreateMessageDraftInput): Promise { + const id = createId() + await db.insert(messageDrafts).values({ + id, + userId: data.userId, + receiverId: data.receiverId ?? null, + subject: data.subject ?? null, + content: data.content ?? null, + parentMessageId: data.parentMessageId ?? null, + // P2-10: 记录来源设备 + deviceId: data.deviceId ?? null, + // P2-10: 初始版本号 1 + version: 1, + }) + return id +} + +/** + * P2-10: 更新草稿(带乐观锁)。 + * + * - 客户端传入 expectedVersion,与数据库当前 version 比对 + * - 匹配则更新并 version+1 + * - 不匹配(冲突)返回 "conflict",客户端应拉取最新版本并提示用户 + * + * 返回值: + * - "ok": 更新成功 + * - "not_found": 草稿不存在或不属于当前用户 + * - "conflict": 版本冲突(其他设备已更新) + */ +export async function updateMessageDraft( + id: string, + userId: string, + data: UpdateMessageDraftInput, +): Promise<"ok" | "not_found" | "conflict"> { + // P2-10: 乐观锁 - 先查询当前版本 + const [existing] = await db + .select({ id: messageDrafts.id, version: messageDrafts.version }) + .from(messageDrafts) + .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) + .limit(1) + + if (!existing) return "not_found" + + // P2-10: 版本冲突检测 + if (data.expectedVersion !== undefined && data.expectedVersion !== existing.version) { + return "conflict" + } + + await db + .update(messageDrafts) + .set({ + ...(data.receiverId !== undefined && { receiverId: data.receiverId }), + ...(data.subject !== undefined && { subject: data.subject }), + ...(data.content !== undefined && { content: data.content }), + ...(data.parentMessageId !== undefined && { parentMessageId: data.parentMessageId }), + ...(data.deviceId !== undefined && { deviceId: data.deviceId }), + // P2-10: 版本号自增 + version: existing.version + 1, + }) + .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) + + return "ok" +} + +export async function deleteMessageDraft(id: string, userId: string): Promise { + await db + .delete(messageDrafts) + .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) +} + +export async function getMessageDraftById(id: string, userId: string): Promise { + const [row] = await db + .select() + .from(messageDrafts) + .where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId))) + .limit(1) + + if (!row) return null + + const nameMap = row.receiverId ? await resolveUserNames([row.receiverId]) : new Map() + return mapDraft(row, nameMap) +} + +// --------------------------------------------------------------------------- +// P2-6: 快捷回复模板 +// --------------------------------------------------------------------------- + +interface MessageTemplateRow { + id: string + userId: string + title: string + content: string + sortOrder: number + updatedAt: Date + createdAt: Date +} + +const mapTemplate = (r: MessageTemplateRow): MessageTemplate => ({ + id: r.id, + userId: r.userId, + title: r.title, + content: r.content, + sortOrder: r.sortOrder, + updatedAt: toIsoRequired(r.updatedAt), + createdAt: toIsoRequired(r.createdAt), +}) + +export const getMessageTemplatesRaw = async ( + userId: string, +): Promise => { + const rows = await db + .select() + .from(messageTemplates) + .where(eq(messageTemplates.userId, userId)) + .orderBy(messageTemplates.sortOrder, desc(messageTemplates.createdAt)) + return rows.map(mapTemplate) +} + +export const getMessageTemplates = cacheFn(getMessageTemplatesRaw, { + tags: ["messaging"], + ttl: 60, + keyParts: ["messaging", "getMessageTemplates"], +}) + +export async function createMessageTemplate(data: CreateMessageTemplateInput): Promise { + const id = createId() + await db.insert(messageTemplates).values({ + id, + userId: data.userId, + title: data.title, + content: data.content, + sortOrder: data.sortOrder ?? 0, + }) + return id +} + +export async function updateMessageTemplate( + id: string, + userId: string, + data: UpdateMessageTemplateInput, +): Promise { + const updateData: Partial<{ title: string; content: string; sortOrder: number }> = {} + if (data.title !== undefined) updateData.title = data.title + if (data.content !== undefined) updateData.content = data.content + if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder + + if (Object.keys(updateData).length === 0) return + + await db + .update(messageTemplates) + .set(updateData) + .where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId))) +} + +export async function deleteMessageTemplate(id: string, userId: string): Promise { + await db + .delete(messageTemplates) + .where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId))) +} diff --git a/src/modules/notifications/lib/type-guards.ts b/src/modules/notifications/lib/type-guards.ts new file mode 100644 index 0000000..82771cb --- /dev/null +++ b/src/modules/notifications/lib/type-guards.ts @@ -0,0 +1,12 @@ +/** + * notifications 模块类型守卫 + */ + +/** 判断值是否为字符串字典(Record) */ +export function isStringRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false + for (const v of Object.values(value as Record)) { + if (typeof v !== "string") return false + } + return true +} diff --git a/src/modules/parent/actions.ts b/src/modules/parent/actions.ts new file mode 100644 index 0000000..7d2506c --- /dev/null +++ b/src/modules/parent/actions.ts @@ -0,0 +1,97 @@ +"use server" + +import { requireAuth } from "@/shared/lib/auth-guard" +import { + getChildren, + getChildBasicInfo, + getChildDashboardData, + getChildNameList, + getParentDashboardData, + verifyParentChildRelation, +} from "./data-access" +import type { + ParentChildRelation, + ChildBasicInfo, + ChildDashboardData, + ParentDashboardData, +} from "./types" + +/** + * 家长端 Server Actions + * + * 审计 v1 P0 修复(G4-002):原 parent 模块缺失 actions.ts, + * app 路由直接 import data-access 绕过权限校验。 + * 现统一通过 Server Action 入口调用,每个 Action 包含 requireAuth + 权限校验。 + * + * 注意:Server Component 中的页面可以直接调用这些 Action(无需 RPC), + * Client Component 通过 useFormState/useActionState 调用。 + */ + +/** + * 获取当前家长的所有子女关系列表。 + * 权限:DASHBOARD_PARENT_READ(家长查看自己仪表盘的权限)。 + */ +export async function getChildrenAction(): Promise { + const ctx = await requireAuth() + return getChildren(ctx.userId) +} + +/** + * 获取子女基本信息(含班级/年级)。 + * 权限:必须通过 verifyParentChildRelation 校验家长与子女关系。 + */ +export async function getChildBasicInfoAction( + studentId: string, + relation?: string | null, +): Promise { + const ctx = await requireAuth() + // 安全校验:当前家长必须与该子女存在关系 + const verifiedRelation = await verifyParentChildRelation(studentId, ctx.userId) + if (!verifiedRelation) return null + return getChildBasicInfo(studentId, relation ?? verifiedRelation) +} + +/** + * 获取子女仪表盘数据(聚合课表、作业、成绩、考试等)。 + * 权限:必须通过 verifyParentChildRelation 校验家长与子女关系。 + */ +export async function getChildDashboardDataAction( + studentId: string, +): Promise { + const ctx = await requireAuth() + // 安全校验:当前家长必须与该子女存在关系 + const relation = await verifyParentChildRelation(studentId, ctx.userId) + if (!relation) return null + return getChildDashboardData(studentId, relation) +} + +/** + * 获取家长仪表盘数据(聚合所有子女的概览)。 + * 权限:DASHBOARD_PARENT_READ。 + */ +export async function getParentDashboardDataAction(): Promise { + const ctx = await requireAuth() + return getParentDashboardData(ctx.userId) +} + +/** + * 获取当前家长的子女名称列表(用于多子女切换器)。 + * 权限:DASHBOARD_PARENT_READ。 + */ +export async function getChildNameListAction(): Promise< + Array<{ id: string; name: string | null }> +> { + const ctx = await requireAuth() + return getChildNameList(ctx.userId) +} + +/** + * 校验当前家长与指定子女的关系(用于页面级访问控制)。 + * 权限:requireAuth(登录态即可,关系校验由 verifyParentChildRelation 完成)。 + */ +export async function verifyParentChildRelationAction( + studentId: string, +): Promise { + const ctx = await requireAuth() + return verifyParentChildRelation(studentId, ctx.userId) +} diff --git a/src/modules/scheduling/lib/schedule-validation.ts b/src/modules/scheduling/lib/schedule-validation.ts new file mode 100644 index 0000000..f870b94 --- /dev/null +++ b/src/modules/scheduling/lib/schedule-validation.ts @@ -0,0 +1,158 @@ +/** + * 课表项纯校验函数(A-02 修复:从 data-access-class-schedule.ts 抽离)。 + * + * 设计原则: + * - 纯函数,无 DB 访问、无副作用,便于单元测试 + * - data-access 层调用这些函数完成字段校验,DB 相关的归属校验仍保留在 data-access + * - 错误以 throw Error 形式抛出,由调用方(actions 层)捕获并翻译为 i18n 文案 + */ + +import type { + CreateClassScheduleItemInput, + UpdateClassScheduleItemInput, +} from "../types" + +/** HH:MM 时间格式校验 */ +export const isTimeHHMM = (v: string): boolean => /^\d{2}:\d{2}$/.test(v) + +/** 星期 1-7 范围校验 */ +export function isValidWeekday(weekday: number): boolean { + return weekday >= 1 && weekday <= 7 +} + +/** 时间区间校验:start 必须早于 end */ +export function isTimeRangeValid(startTime: string, endTime: string): boolean { + return startTime < endTime +} + +/** location 归一化:trim 后空字符串转为 null */ +export function normalizeLocation(value: string | null | undefined): string | null { + return value?.trim() || null +} + +/** 已存在的课表项(用于 update 时的合并校验) */ +export interface ScheduleItemExisting { + classId: string + startTime: string + endTime: string +} + +/** 归一化并校验创建输入,返回可直接写入 DB 的字段 */ +export interface NormalizedScheduleCreateInput { + classId: string + weekday: number + startTime: string + endTime: string + course: string + location: string | null +} + +/** + * 归一化并校验创建课表项的输入。 + * 抛出 Error 表示校验失败(消息为英文简述,由调用方翻译)。 + */ +export function normalizeAndValidateCreateInput( + data: CreateClassScheduleItemInput, +): NormalizedScheduleCreateInput { + const classId = data.classId.trim() + const course = data.course.trim() + const startTime = data.startTime.trim() + const endTime = data.endTime.trim() + const location = normalizeLocation(data.location) + const weekday = data.weekday + + if (!classId) throw new Error("Class is required") + if (!course) throw new Error("Course is required") + if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) { + throw new Error("Invalid time format") + } + if (!isTimeRangeValid(startTime, endTime)) { + throw new Error("Start time must be earlier than end time") + } + if (!isValidWeekday(weekday)) throw new Error("Invalid weekday") + + return { classId, weekday, startTime, endTime, course, location } +} + +/** + * 根据 update 输入与已存在记录,构建校验通过的 update 字段对象。 + * + * 注意:classId 字段的归属校验(verifyTeacherOwnsClass)是 DB 相关操作, + * 仍由 data-access 层完成;本函数仅做 trim + 非空校验,并将待校验的 + * nextClassId 通过返回值传出,供 data-access 层做归属校验。 + * + * 返回: + * - update: 可直接传给 DB update 的字段对象(可能为空,表示无需更新) + * - nextClassId: 若 classId 需要变更,则为 trim 后的新 classId(待归属校验) + */ +export function buildValidatedScheduleUpdate( + existing: ScheduleItemExisting, + data: UpdateClassScheduleItemInput, +): { + update: Partial<{ + classId: string + weekday: number + startTime: string + endTime: string + course: string + location: string | null + }> + nextClassId?: string +} { + const update: { + classId?: string + weekday?: number + startTime?: string + endTime?: string + course?: string + location?: string | null + } = {} + + let nextClassId: string | undefined + + if (typeof data.classId === "string") { + nextClassId = data.classId.trim() + if (!nextClassId) throw new Error("Class is required") + update.classId = nextClassId + } + + if (typeof data.weekday === "number") { + if (!isValidWeekday(data.weekday)) throw new Error("Invalid weekday") + update.weekday = data.weekday + } + + if (typeof data.course === "string") { + const course = data.course.trim() + if (!course) throw new Error("Course is required") + update.course = course + } + + const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined + const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined + if (nextStart !== undefined) { + if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format") + update.startTime = nextStart + } + if (nextEnd !== undefined) { + if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format") + update.endTime = nextEnd + } + + if (update.startTime !== undefined || update.endTime !== undefined) { + const mergedStart = update.startTime ?? existing.startTime + const mergedEnd = update.endTime ?? existing.endTime + if ( + typeof mergedStart === "string" && + typeof mergedEnd === "string" && + !isTimeRangeValid(mergedStart, mergedEnd) + ) { + throw new Error("Start time must be earlier than end time") + } + } + + if (data.location !== undefined) { + update.location = normalizeLocation(data.location) + } + + return { update, nextClassId } +} diff --git a/src/modules/school/data-access-classrooms.ts b/src/modules/school/data-access-classrooms.ts new file mode 100644 index 0000000..7986bae --- /dev/null +++ b/src/modules/school/data-access-classrooms.ts @@ -0,0 +1,41 @@ +import "server-only" + +/** + * 教室数据访问层(跨模块只读接口) + * + * 审计 G3-007 / S-01 修复:从 data-access.ts 拆分。 + * + * 职责: + * - getClassroomsForScheduling: 跨模块接口,供排课模块获取教室选择器 + */ + +import { asc } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { classrooms } from "@/shared/db/schema" + +/** 教室轻量信息(供排课模块选择器使用) */ +export type ClassroomOption = { + id: string + name: string + building: string | null +} + +/** + * 跨模块接口:获取所有教室的轻量信息(id/name/building),供排课模块选择器使用。 + * + * 避免 scheduling 模块直接查询 classrooms 表。 + */ +export const getClassroomsForSchedulingRaw = async (): Promise => { + const rows = await db + .select({ id: classrooms.id, name: classrooms.name, building: classrooms.building }) + .from(classrooms) + .orderBy(asc(classrooms.name)) + return rows.map((r) => ({ id: r.id, name: r.name, building: r.building ?? null })) +} +export const getClassroomsForScheduling = cacheFn(getClassroomsForSchedulingRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getClassroomsForScheduling"], +}) diff --git a/src/modules/school/data-access-departments.ts b/src/modules/school/data-access-departments.ts new file mode 100644 index 0000000..d0b7f5b --- /dev/null +++ b/src/modules/school/data-access-departments.ts @@ -0,0 +1,64 @@ +import "server-only" + +/** + * 部门数据访问层 + * + * 审计 G3-007 / S-01 修复:从 data-access.ts 拆分,文件行数从 938 降至 < 200。 + * + * 职责: + * - getDepartments: 部门列表查询(带缓存) + * - createDepartment / updateDepartment / deleteDepartment: 部门 CRUD + * + * 变更历史: + * - G3-010 / A-10: 移除 try-catch 错误吞没,让错误向上抛出 + */ + +import { asc, eq } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" +import { db } from "@/shared/db" +import { departments } from "@/shared/db/schema" +import type { + DepartmentInsertData, + DepartmentListItem, + DepartmentUpdateData, +} from "./types" + +export const getDepartmentsRaw = async (): Promise => { + const rows = await db.select().from(departments).orderBy(asc(departments.name)) + return rows.map((r) => ({ + id: r.id, + name: r.name, + description: r.description ?? null, + createdAt: toIso(r.createdAt), + updatedAt: toIso(r.updatedAt), + })) +} +export const getDepartments = cacheFn(getDepartmentsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getDepartments"], +}) + +export async function createDepartment(data: DepartmentInsertData): Promise { + await db.insert(departments).values({ + id: data.id, + name: data.name, + description: data.description, + }) +} + +export async function updateDepartment( + id: string, + data: DepartmentUpdateData +): Promise { + await db + .update(departments) + .set({ name: data.name, description: data.description }) + .where(eq(departments.id, id)) +} + +export async function deleteDepartment(id: string): Promise { + await db.delete(departments).where(eq(departments.id, id)) +} diff --git a/src/modules/school/data-access-grades.ts b/src/modules/school/data-access-grades.ts new file mode 100644 index 0000000..c2c5581 --- /dev/null +++ b/src/modules/school/data-access-grades.ts @@ -0,0 +1,453 @@ +import "server-only" + +/** + * 年级数据访问层 + * + * 审计 G3-007 / S-01 修复:从 data-access.ts 拆分。 + * + * 职责: + * - getGrades / getGradesForStaff: 年级列表查询(带缓存,含年级主任/教学主任信息) + * - getGradeOptions / getGradeNameById: 跨模块只读接口 + * - isGradeHead / isGradeManager / findGradeIdByHeadAndName: 跨模块权限校验 + * - createGrade / updateGrade / deleteGrade: 年级 CRUD + * - promoteGrades: 年级升级(order +1 + 名称升级) + * - getGradeOverviewStats: 年级概览统计 + * + * 变更历史: + * - G3-010 / A-10: 移除 try-catch 错误吞没,让错误向上抛出 + * - G3-012 / F-09: promoteGrades 改用 db.transaction 包裹循环 UPDATE + * - S-03: 抽取 fetchGradesWithHeads helper,消除 3 处重复代码 + */ + +import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" +import { db } from "@/shared/db" +import { grades, schools, users } from "@/shared/db/schema" +import type { + GradeInsertData, + GradeListItem, + GradeUpdateData, + StaffOption, +} from "./types" + +// --------------------------------------------------------------------------- +// 共享 helper(S-03 修复:消除 getGrades / getGradesForStaff / getGradesForUser 三处重复) +// --------------------------------------------------------------------------- + +interface GradeRowWithHeadIds { + id: string + name: string + order: number | null + schoolId: string + schoolName: string + gradeHeadId: string | null + teachingHeadId: string | null + createdAt: Date + updatedAt: Date +} + +/** + * 按 where 条件查询年级(含学校信息),并 JOIN 出年级主任/教学主任的用户信息。 + * 三个查询函数(getGrades / getGradesForStaff / getGradesByIds) + * 共享此 helper,消除"查询年级 + 批量查询主任用户 + 映射"的重复代码(S-03 修复)。 + */ +async function fetchGradesWithHeads( + where?: SQL +): Promise { + const rows = await db + .select({ + id: grades.id, + name: grades.name, + order: grades.order, + schoolId: schools.id, + schoolName: schools.name, + gradeHeadId: grades.gradeHeadId, + teachingHeadId: grades.teachingHeadId, + createdAt: grades.createdAt, + updatedAt: grades.updatedAt, + }) + .from(grades) + .innerJoin(schools, eq(schools.id, grades.schoolId)) + .where(where) + .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) + + return mapGradesWithHeads(rows) +} + +/** 将已查询的年级行映射为 GradeListItem[](含批量查询主任用户信息) */ +async function mapGradesWithHeads( + rows: GradeRowWithHeadIds[] +): Promise { + const headIds = Array.from( + new Set( + rows + .flatMap((r) => [r.gradeHeadId, r.teachingHeadId]) + .filter((v): v is string => typeof v === "string" && v.length > 0) + ) + ) + + const heads = headIds.length + ? await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(inArray(users.id, headIds)) + : [] + + const headById = new Map() + for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email }) + + return rows.map((r) => ({ + id: r.id, + school: { id: r.schoolId, name: r.schoolName }, + name: r.name, + order: Number(r.order ?? 0), + gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null, + teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null, + createdAt: toIso(r.createdAt), + updatedAt: toIso(r.updatedAt), + })) +} + +// --------------------------------------------------------------------------- +// 只读查询 +// --------------------------------------------------------------------------- + +export const getGradesRaw = async (): Promise => { + return await fetchGradesWithHeads() +} +export const getGrades = cacheFn(getGradesRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGrades"], +}) + +export const getGradesForStaffRaw = async (staffId: string): Promise => { + const id = staffId.trim() + if (!id) return [] + + return await fetchGradesWithHeads( + or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id)) + ) +} +export const getGradesForStaff = cacheFn(getGradesForStaffRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradesForStaff"], +}) + +/** + * 按 ID 列表批量获取年级(含学校信息 + 年级主任/教学主任)。 + * 供 lib/school-permissions.ts 的 teacher 角色分支使用,避免重复实现 fetchGradesWithHeads 逻辑(S-03 修复)。 + */ +export const getGradesByIdsRaw = async (gradeIds: string[]): Promise => { + const ids = Array.from(new Set(gradeIds.filter((id) => id.trim().length > 0))) + if (ids.length === 0) return [] + + return await fetchGradesWithHeads(inArray(grades.id, ids)) +} +export const getGradesByIds = cacheFn(getGradesByIdsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradesByIds"], +}) + +// --------------------------------------------------------------------------- +// 跨模块只读接口 +// --------------------------------------------------------------------------- + +export type GradeOption = { + id: string + name: string + schoolId: string + schoolName: string + order: number +} + +export const getGradeOptionsRaw = async (): Promise => { + const rows = await db + .select({ + id: grades.id, + name: grades.name, + order: grades.order, + schoolId: schools.id, + schoolName: schools.name, + }) + .from(grades) + .innerJoin(schools, eq(schools.id, grades.schoolId)) + .orderBy(asc(schools.name), asc(grades.order), asc(grades.name)) + + return rows.map((r) => ({ + id: r.id, + name: r.name, + schoolId: r.schoolId, + schoolName: r.schoolName, + order: Number(r.order ?? 0), + })) +} +export const getGradeOptions = cacheFn(getGradeOptionsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradeOptions"], +}) + +/** + * 按 ID 获取单个年级名称。 + * 供跨模块调用使用,避免为单个年级拉取全量年级选项。 + */ +export const getGradeNameByIdRaw = async ( + gradeId: string +): Promise => { + const id = gradeId.trim() + if (!id) return null + const [row] = await db + .select({ name: grades.name }) + .from(grades) + .where(eq(grades.id, id)) + .limit(1) + return row?.name ?? null +} +export const getGradeNameById = cacheFn(getGradeNameByIdRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradeNameById"], +}) + +// --------------------------------------------------------------------------- +// 跨模块权限校验接口 +// --------------------------------------------------------------------------- + +/** + * 校验用户是否为指定年级的年级主任。 + * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 + */ +export const isGradeHeadRaw = async ( + gradeId: string, + userId: string +): Promise => { + const trimmedGradeId = gradeId.trim() + const trimmedUserId = userId.trim() + if (!trimmedGradeId || !trimmedUserId) return false + + const [row] = await db + .select({ id: grades.id }) + .from(grades) + .where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId))) + .limit(1) + return Boolean(row) +} +export const isGradeHead = cacheFn(isGradeHeadRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "isGradeHead"], +}) + +/** + * 校验用户是否为指定年级的年级主任或教学主任。 + * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 + */ +export const isGradeManagerRaw = async ( + gradeId: string, + userId: string +): Promise => { + const trimmedGradeId = gradeId.trim() + const trimmedUserId = userId.trim() + if (!trimmedGradeId || !trimmedUserId) return false + + const [row] = await db + .select({ id: grades.id }) + .from(grades) + .where( + and( + eq(grades.id, trimmedGradeId), + or(eq(grades.gradeHeadId, trimmedUserId), eq(grades.teachingHeadId, trimmedUserId)) + ) + ) + .limit(1) + return Boolean(row) +} +export const isGradeManager = cacheFn(isGradeManagerRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "isGradeManager"], +}) + +/** + * 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。 + * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 + */ +export const findGradeIdByHeadAndNameRaw = async ( + userId: string, + gradeName: string +): Promise => { + const trimmedUserId = userId.trim() + const normalizedGradeName = gradeName.trim().toLowerCase() + if (!trimmedUserId || !normalizedGradeName) return null + + const [row] = await db + .select({ id: grades.id }) + .from(grades) + .where( + and( + eq(grades.gradeHeadId, trimmedUserId), + sql`LOWER(${grades.name}) = ${normalizedGradeName}` + ) + ) + .limit(1) + return row?.id ?? null +} +export const findGradeIdByHeadAndName = cacheFn(findGradeIdByHeadAndNameRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "findGradeIdByHeadAndName"], +}) + +// --------------------------------------------------------------------------- +// Mutations — DB write operations (called only from actions.ts) +// --------------------------------------------------------------------------- + +export async function createGrade(data: GradeInsertData): Promise { + await db.insert(grades).values({ + id: data.id, + schoolId: data.schoolId, + name: data.name, + order: data.order, + gradeHeadId: data.gradeHeadId, + teachingHeadId: data.teachingHeadId, + }) +} + +export async function updateGrade( + id: string, + data: GradeUpdateData +): Promise { + await db + .update(grades) + .set({ + schoolId: data.schoolId, + name: data.name, + order: data.order, + gradeHeadId: data.gradeHeadId, + teachingHeadId: data.teachingHeadId, + }) + .where(eq(grades.id, id)) +} + +export async function deleteGrade(id: string): Promise { + await db.delete(grades).where(eq(grades.id, id)) +} + +// --------------------------------------------------------------------------- +// 年级升级 +// --------------------------------------------------------------------------- + +/** + * 将年级名称升级:一年级→二年级,1年级→2年级,Grade 1→Grade 2 + * 无法识别的名称保持不变。 + */ +function promoteGradeName(name: string): string { + // 中文数字映射 + const chineseNums = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"] + for (let i = 0; i < chineseNums.length - 1; i++) { + if (name.includes(chineseNums[i]) && !name.includes(chineseNums[i + 1])) { + return name.replace(chineseNums[i], chineseNums[i + 1]) + } + } + + // 阿拉伯数字 + const match = name.match(/(\d+)/) + if (match) { + const num = parseInt(match[1], 10) + if (num > 0 && num < 13) { + return name.replace(match[1], String(num + 1)) + } + } + + return name +} + +/** + * 年级升级:将指定学校下的所有年级 order +1,并更新年级名称(如果符合数字模式)。 + * 例如:一年级 → 二年级,order 1 → 2 + * + * G3-012 / F-09 修复:使用 db.transaction 包裹循环 UPDATE,保证原子性。 + * 任意一条更新失败将回滚整个升级操作,避免出现部分年级已升级、部分未升级的不一致状态。 + * + * 注意:此函数只更新年级本身的 order 和 name,不迁移班级数据。 + * 班级升级应由 classes 模块的独立 Action 处理。 + */ +export async function promoteGrades(schoolId: string): Promise<{ promoted: number }> { + return await db.transaction(async (tx) => { + const rows = await tx + .select({ id: grades.id, name: grades.name, order: grades.order }) + .from(grades) + .where(eq(grades.schoolId, schoolId)) + .orderBy(desc(grades.order)) // 从高到低升级,避免唯一约束冲突 + + let promoted = 0 + for (const row of rows) { + const newOrder = (row.order ?? 0) + 1 + const newName = promoteGradeName(row.name) + await tx + .update(grades) + .set({ order: newOrder, name: newName }) + .where(eq(grades.id, row.id)) + promoted += 1 + } + + return { promoted } + }) +} + +// --------------------------------------------------------------------------- +// 年级概览统计 +// --------------------------------------------------------------------------- + +/** + * 年级概览统计:每个年级的班级数、学生数、教师数。 + * 用于年级管理页面的卡片视图,让管理员一目了然看到各年级规模。 + */ +export interface GradeOverviewStats { + gradeId: string + classCount: number + studentCount: number + teacherCount: number +} + +export const getGradeOverviewStatsRaw = async (): Promise => { + // 动态导入 classes 模块避免循环依赖 + const { getAdminClasses } = await import("@/modules/classes/data-access") + const allClasses = await getAdminClasses() + + // 按年级分组统计班级数和学生数 + const statsByGrade = new Map }>() + + for (const cls of allClasses) { + const gradeId = cls.gradeId + if (typeof gradeId !== "string" || gradeId.length === 0) continue + + const existing = statsByGrade.get(gradeId) ?? { classCount: 0, studentCount: 0, teacherIds: new Set() } + existing.classCount += 1 + existing.studentCount += cls.studentCount ?? 0 + // 班主任算一个教师 + if (cls.teacher?.id) existing.teacherIds.add(cls.teacher.id) + // 学科教师也算 + for (const st of cls.subjectTeachers ?? []) { + if (st.teacher?.id) existing.teacherIds.add(st.teacher.id) + } + statsByGrade.set(gradeId, existing) + } + + return Array.from(statsByGrade.entries()).map(([gradeId, s]) => ({ + gradeId, + classCount: s.classCount, + studentCount: s.studentCount, + teacherCount: s.teacherIds.size, + })) +} +export const getGradeOverviewStats = cacheFn(getGradeOverviewStatsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradeOverviewStats"], +}) diff --git a/src/modules/school/data-access-schools.ts b/src/modules/school/data-access-schools.ts new file mode 100644 index 0000000..c5cff57 --- /dev/null +++ b/src/modules/school/data-access-schools.ts @@ -0,0 +1,160 @@ +import "server-only" + +/** + * 学校数据访问层 + 员工选项 + 组织架构树 + * + * 审计 G3-007 / S-01 修复:从 data-access.ts 拆分。 + * + * 职责: + * - getSchools: 学校列表查询(带缓存) + * - createSchool / updateSchool / deleteSchool: 学校 CRUD + * - getStaffOptions: 员工选项(教师 + 管理员) + * - getOrgTree: 学校→年级→班级三级组织架构树 + * + * 变更历史: + * - G3-010 / A-10: 移除 try-catch 错误吞没,让错误向上抛出 + * - G3-011 / A-02: 角色判断逻辑(getSchoolsForUser / getGradesForUser)迁出至 lib/school-permissions.ts + */ + +import { asc, eq, inArray } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" +import { db } from "@/shared/db" +import { roles, schools, users, usersToRoles } from "@/shared/db/schema" +import type { + OrgTreeNode, + SchoolInsertData, + SchoolListItem, + SchoolUpdateData, + StaffOption, +} from "./types" +import { getGrades } from "./data-access-grades" + +export const getSchoolsRaw = async (): Promise => { + const rows = await db.select().from(schools).orderBy(asc(schools.name)) + return rows.map((r) => ({ + id: r.id, + name: r.name, + code: r.code ?? null, + createdAt: toIso(r.createdAt), + updatedAt: toIso(r.updatedAt), + })) +} +export const getSchools = cacheFn(getSchoolsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSchools"], +}) + +export async function createSchool(data: SchoolInsertData): Promise { + await db.insert(schools).values({ + id: data.id, + name: data.name, + code: data.code, + }) +} + +export async function updateSchool( + id: string, + data: SchoolUpdateData +): Promise { + await db + .update(schools) + .set({ name: data.name, code: data.code }) + .where(eq(schools.id, id)) +} + +export async function deleteSchool(id: string): Promise { + await db.delete(schools).where(eq(schools.id, id)) +} + +// --------------------------------------------------------------------------- +// 员工选项(教师 + 管理员) +// --------------------------------------------------------------------------- + +export const getStaffOptionsRaw = async (): Promise => { + const rows = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(inArray(roles.name, ["teacher", "admin"])) + .groupBy(users.id, users.name, users.email) + .orderBy(asc(users.name), asc(users.email)) + + return rows.map((r) => ({ + id: r.id, + name: r.name ?? "Unnamed", + email: r.email, + })) +} +export const getStaffOptions = cacheFn(getStaffOptionsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getStaffOptions"], +}) + +// --------------------------------------------------------------------------- +// 组织架构树 +// --------------------------------------------------------------------------- + +/** + * 获取学校→年级→班级三级组织架构树。 + * 班级数据通过 classes 模块的 data-access 动态导入获取,避免循环依赖。 + * + * G3-010 / A-10 修复:移除 try-catch 错误吞没,让错误向上抛出。 + * 调用方(页面层)应自行包裹 error boundary 处理异常。 + */ +export const getOrgTreeRaw = async (): Promise => { + const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()]) + + const { getAdminClasses } = await import("@/modules/classes/data-access") + const allClasses = await getAdminClasses() + + const classesByGradeId = new Map() + for (const cls of allClasses) { + const gradeId = cls.gradeId + if (typeof gradeId !== "string" || gradeId.length === 0) continue + const node: OrgTreeNode = { + id: cls.id, + name: cls.name, + type: "class", + } + const list = classesByGradeId.get(gradeId) + if (list) { + list.push(node) + } else { + classesByGradeId.set(gradeId, [node]) + } + } + + const gradesBySchoolId = new Map() + for (const grade of gradeList) { + const children = classesByGradeId.get(grade.id) ?? [] + const node: OrgTreeNode = { + id: grade.id, + name: grade.name, + type: "grade", + children, + } + const list = gradesBySchoolId.get(grade.school.id) + if (list) { + list.push(node) + } else { + gradesBySchoolId.set(grade.school.id, [node]) + } + } + + return schoolList.map((school) => ({ + id: school.id, + name: school.name, + type: "school", + children: gradesBySchoolId.get(school.id) ?? [], + })) +} +export const getOrgTree = cacheFn(getOrgTreeRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getOrgTree"], +}) diff --git a/src/modules/school/data-access-semesters.ts b/src/modules/school/data-access-semesters.ts new file mode 100644 index 0000000..f4277f2 --- /dev/null +++ b/src/modules/school/data-access-semesters.ts @@ -0,0 +1,85 @@ +import "server-only" + +/** + * 学年(学期)数据访问层 + * + * 审计 G3-007 / S-01 修复:从 data-access.ts 拆分。 + * + * 职责: + * - getAcademicYears: 学年列表查询(带缓存) + * - createAcademicYear / updateAcademicYear / deleteAcademicYear: 学年 CRUD + * + * 变更历史: + * - G3-010 / A-10: 移除 try-catch 错误吞没,让错误向上抛出 + */ + +import { asc, eq } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" +import { db } from "@/shared/db" +import { academicYears } from "@/shared/db/schema" +import type { + AcademicYearInsertData, + AcademicYearListItem, + AcademicYearUpdateData, +} from "./types" + +export const getAcademicYearsRaw = async (): Promise => { + const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate)) + return rows.map((r) => ({ + id: r.id, + name: r.name, + startDate: toIso(r.startDate), + endDate: toIso(r.endDate), + isActive: Boolean(r.isActive), + createdAt: toIso(r.createdAt), + updatedAt: toIso(r.updatedAt), + })) +} +export const getAcademicYears = cacheFn(getAcademicYearsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getAcademicYears"], +}) + +export async function createAcademicYear( + data: AcademicYearInsertData +): Promise { + await db.transaction(async (tx) => { + if (data.isActive) { + await tx.update(academicYears).set({ isActive: false }) + } + await tx.insert(academicYears).values({ + id: data.id, + name: data.name, + startDate: data.startDate, + endDate: data.endDate, + isActive: data.isActive, + }) + }) +} + +export async function updateAcademicYear( + id: string, + data: AcademicYearUpdateData +): Promise { + await db.transaction(async (tx) => { + if (data.isActive) { + await tx.update(academicYears).set({ isActive: false }) + } + await tx + .update(academicYears) + .set({ + name: data.name, + startDate: data.startDate, + endDate: data.endDate, + isActive: data.isActive, + }) + .where(eq(academicYears.id, id)) + }) +} + +export async function deleteAcademicYear(id: string): Promise { + await db.delete(academicYears).where(eq(academicYears.id, id)) +} diff --git a/src/modules/school/data-access-subjects.ts b/src/modules/school/data-access-subjects.ts new file mode 100644 index 0000000..1b6a93e --- /dev/null +++ b/src/modules/school/data-access-subjects.ts @@ -0,0 +1,97 @@ +import "server-only" + +/** + * 科目数据访问层(跨模块只读接口) + * + * 审计 G3-007 / S-01 修复:从 data-access.ts 拆分。 + * + * 职责: + * - getSubjectOptions: 科目选项列表(供排课/作业/成绩等模块选择器使用) + * - getSubjectNameById: 按 ID 获取单个科目名称 + * - getSubjectNameMapByIds: 批量获取科目名称映射 + * + * 变更历史: + * - G3-010 / A-10: 移除 try-catch 错误吞没,让错误向上抛出 + */ + +import { asc, eq, inArray } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { subjects } from "@/shared/db/schema" + +export type SubjectOption = { + id: string + name: string + code: string | null + order: number +} + +export const getSubjectOptionsRaw = async (): Promise => { + const rows = await db + .select({ + id: subjects.id, + name: subjects.name, + code: subjects.code, + order: subjects.order, + }) + .from(subjects) + .orderBy(asc(subjects.order), asc(subjects.name)) + + return rows.map((r) => ({ + id: r.id, + name: r.name, + code: r.code ?? null, + order: Number(r.order ?? 0), + })) +} +export const getSubjectOptions = cacheFn(getSubjectOptionsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSubjectOptions"], +}) + +/** + * 按 ID 获取单个科目名称。 + * 供跨模块调用使用,避免为单个科目拉取全量科目选项或直接查询 subjects 表。 + */ +export const getSubjectNameByIdRaw = async ( + subjectId: string +): Promise => { + const id = subjectId.trim() + if (!id) return null + const [row] = await db + .select({ name: subjects.name }) + .from(subjects) + .where(eq(subjects.id, id)) + .limit(1) + return row?.name ?? null +} +export const getSubjectNameById = cacheFn(getSubjectNameByIdRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSubjectNameById"], +}) + +/** + * 批量获取科目名称映射(subjectId -> name)。 + * 供跨模块调用使用,避免直接查询 subjects 表。 + */ +export const getSubjectNameMapByIdsRaw = async ( + subjectIds: string[] +): Promise> => { + const ids = subjectIds.filter((id) => id.trim().length > 0) + if (ids.length === 0) return new Map() + const rows = await db + .select({ id: subjects.id, name: subjects.name }) + .from(subjects) + .where(inArray(subjects.id, ids)) + const map = new Map() + for (const r of rows) map.set(r.id, r.name) + return map +} +export const getSubjectNameMapByIds = cacheFn(getSubjectNameMapByIdsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSubjectNameMapByIds"], +}) diff --git a/src/modules/school/lib/school-permissions.ts b/src/modules/school/lib/school-permissions.ts new file mode 100644 index 0000000..bdba7b3 --- /dev/null +++ b/src/modules/school/lib/school-permissions.ts @@ -0,0 +1,156 @@ +import "server-only" + +/** + * 学校模块权限感知查询(角色判断 + 数据范围过滤) + * + * 审计 G3-011 / A-02 修复:从 data-access.ts 迁出角色判断逻辑。 + * data-access 层应只接受显式参数(如 scope filter),不应直接检查角色。 + * 角色判断 + 数据范围过滤属于业务编排层,放在 lib 中供 actions / 页面层调用。 + * + * 职责: + * - getSchoolsForUser(userId): 根据用户角色返回可见学校列表 + * - getGradesForUser(userId): 根据用户角色返回可见年级列表 + * + * 角色规则: + * - admin: 返回全量数据 + * - grade_head / teaching_head: 返回其负责年级所在数据 + * - teacher: 返回其任课班级所在数据 + * - 其他角色: 返回空数组 + * + * 变更历史: + * - G3-010 / A-10: 移除 try-catch 错误吞没,让错误向上抛出 + */ + +import { asc, eq, inArray, or } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { grades, roles, schools, usersToRoles } from "@/shared/db/schema" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" +import type { + GradeListItem, + SchoolListItem, +} from "../types" +import { getGrades, getGradesForStaff, getGradesByIds } from "../data-access-grades" +import { getSchools } from "../data-access-schools" + +/** + * 根据用户角色返回可见的学校列表(权限感知)。 + * - admin: 返回全量学校 + * - grade_head / teaching_head: 返回其负责年级所在学校 + * - teacher: 返回其任课班级所在学校 + * - 其他角色: 返回空数组 + */ +export const getSchoolsForUserRaw = async (userId: string): Promise => { + const id = userId.trim() + if (!id) return [] + + const roleRows = await db + .select({ name: roles.name }) + .from(roles) + .innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id)) + .where(eq(usersToRoles.userId, id)) + + const roleNames = new Set(roleRows.map((r) => r.name)) + + if (roleNames.has("admin")) { + return await getSchools() + } + + let schoolIds: string[] = [] + + if (roleNames.has("grade_head") || roleNames.has("teaching_head")) { + const gradeRows = await db + .select({ schoolId: grades.schoolId }) + .from(grades) + .where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id))) + schoolIds = gradeRows.map((r) => r.schoolId) + } else if (roleNames.has("teacher")) { + const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access") + const classIds = await getAccessibleClassIdsForTeacher(id) + if (classIds.length === 0) return [] + + const gradeIds = await getGradeIdsByClassIds(classIds) + if (gradeIds.length === 0) return [] + + const gradeRows = await db + .select({ schoolId: grades.schoolId }) + .from(grades) + .where(inArray(grades.id, gradeIds)) + schoolIds = gradeRows.map((r) => r.schoolId) + } else { + return [] + } + + const uniqueSchoolIds = Array.from( + new Set(schoolIds.filter((v): v is string => typeof v === "string" && v.length > 0)) + ) + if (uniqueSchoolIds.length === 0) return [] + + const rows = await db + .select() + .from(schools) + .where(inArray(schools.id, uniqueSchoolIds)) + .orderBy(asc(schools.name)) + + return rows.map((r) => ({ + id: r.id, + name: r.name, + code: r.code ?? null, + createdAt: toIso(r.createdAt), + updatedAt: toIso(r.updatedAt), + })) +} +export const getSchoolsForUser = cacheFn(getSchoolsForUserRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSchoolsForUser"], +}) + +/** + * 根据用户角色返回可见的年级列表(权限感知)。 + * - admin: 返回全量年级 + * - grade_head / teaching_head: 返回其负责的年级 + * - teacher: 返回其任课班级所在年级 + * - 其他角色: 返回空数组 + * + * S-03 修复:teacher 分支复用 data-access.getGradesByIds,消除 fetchGradesWithHeads 重复。 + */ +export const getGradesForUserRaw = async (userId: string): Promise => { + const id = userId.trim() + if (!id) return [] + + const roleRows = await db + .select({ name: roles.name }) + .from(roles) + .innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id)) + .where(eq(usersToRoles.userId, id)) + + const roleNames = new Set(roleRows.map((r) => r.name)) + + if (roleNames.has("admin")) { + return await getGrades() + } + + if (roleNames.has("grade_head") || roleNames.has("teaching_head")) { + return await getGradesForStaff(id) + } + + if (roleNames.has("teacher")) { + const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access") + const classIds = await getAccessibleClassIdsForTeacher(id) + if (classIds.length === 0) return [] + + const gradeIds = await getGradeIdsByClassIds(classIds) + if (gradeIds.length === 0) return [] + + return await getGradesByIds(gradeIds) + } + + return [] +} +export const getGradesForUser = cacheFn(getGradesForUserRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradesForUser"], +}) diff --git a/src/modules/settings/lib/type-guards.ts b/src/modules/settings/lib/type-guards.ts new file mode 100644 index 0000000..f2affb4 --- /dev/null +++ b/src/modules/settings/lib/type-guards.ts @@ -0,0 +1,26 @@ +import type { FileUploadResult } from "@/modules/files/types" + +/** 判断值是否为普通对象(非数组、非 null) */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** 判断值是否为携带 message 字段的对象(HTTP 错误响应) */ +export function isMessageBody(value: unknown): value is { message?: string } { + if (!isRecord(value)) return false + if (!("message" in value)) return true + return typeof value.message === "string" +} + +/** 判断值是否为文件上传成功响应 */ +export function isFileUploadResult(value: unknown): value is FileUploadResult { + if (!isRecord(value)) return false + return ( + typeof value.id === "string" && + typeof value.url === "string" && + typeof value.filename === "string" && + typeof value.originalName === "string" && + typeof value.size === "number" && + typeof value.mimeType === "string" + ) +} diff --git a/src/modules/standards/lib/type-guards.ts b/src/modules/standards/lib/type-guards.ts new file mode 100644 index 0000000..7c12909 --- /dev/null +++ b/src/modules/standards/lib/type-guards.ts @@ -0,0 +1,19 @@ +import type { StandardLevel } from "../../lesson-preparation/lib/type-guards" + +const STANDARD_LEVELS = ["national", "curriculum", "custom"] as const + +/** 类型守卫:判断值是否为合法的 StandardLevel */ +export function isStandardLevel(value: unknown): value is StandardLevel { + return ( + typeof value === "string" && + (STANDARD_LEVELS as readonly string[]).includes(value) + ) +} + +/** 安全转换:将未知值转为 StandardLevel,非法值回退到 fallback */ +export function toStandardLevel( + value: unknown, + fallback: StandardLevel = "custom", +): StandardLevel { + return isStandardLevel(value) ? value : fallback +} diff --git a/src/modules/textbooks/lib/type-guards.ts b/src/modules/textbooks/lib/type-guards.ts new file mode 100644 index 0000000..3eb482a --- /dev/null +++ b/src/modules/textbooks/lib/type-guards.ts @@ -0,0 +1,122 @@ +/** + * 教材模块类型守卫集合。 + * + * 替代 components/* 中针对 React Flow `node.data`(Record) + * 与 react-force-graph-2d `NodeObject`/`LinkObject` 的 `as` 断言, + * 从 unknown 安全收窄到具体业务类型,避免脏数据导致运行时类型错误。 + */ + +import type { LinkObject, NodeObject } from "react-force-graph-2d" +import type { GraphLayoutNodeData } from "../graph-layout" +import type { + GraphEdgeData, + GraphNodeData, + KpGraphNode, + KpGraphLink, + KpWithRelations, +} from "../types" + +/** 边类型合法值集合 */ +const GRAPH_EDGE_TYPES: ReadonlySet = new Set(["parent", "prerequisite"]) + +/** + * 类型守卫:判断未知值是否为合法的 KpWithRelations。 + * + * 仅校验关键运行时字段(id/name/questionCount/prerequisiteIds), + * 其余字段由 Zod / data-access 层保证。 + */ +export function isKpWithRelations(data: unknown): data is KpWithRelations { + if (typeof data !== "object" || data === null) return false + return ( + "id" in data && + typeof data.id === "string" && + "name" in data && + typeof data.name === "string" && + "questionCount" in data && + typeof data.questionCount === "number" && + "prerequisiteIds" in data && + Array.isArray(data.prerequisiteIds) + ) +} + +/** + * 类型守卫:判断未知值是否为合法的 GraphNodeData(React Flow 节点 data)。 + */ +export function isGraphNodeData(data: unknown): data is GraphNodeData { + if (typeof data !== "object" || data === null) return false + return ( + "kp" in data && + isKpWithRelations(data.kp) && + "viewMode" in data && + typeof data.viewMode === "string" && + "isSelected" in data && + typeof data.isSelected === "boolean" && + "isHighlighted" in data && + typeof data.isHighlighted === "boolean" && + "chapterColor" in data && + typeof data.chapterColor === "string" + ) +} + +/** + * 类型守卫:判断未知值是否为合法的 GraphLayoutNodeData(dagre 布局节点 data)。 + * + * GraphLayoutNodeData 带有 `[key: string]: unknown` 索引签名, + * 是 React Flow `Node` 的 data 字段类型。 + */ +export function isGraphLayoutNodeData( + data: unknown, +): data is GraphLayoutNodeData { + if (typeof data !== "object" || data === null) return false + return ( + "kp" in data && + isKpWithRelations(data.kp) && + "label" in data && + typeof data.label === "string" + ) +} + +/** + * 类型守卫:判断未知值是否为合法的 GraphEdgeData(React Flow 边 data)。 + */ +export function isGraphEdgeData(data: unknown): data is GraphEdgeData { + if (typeof data !== "object" || data === null) return false + return ( + "edgeType" in data && + typeof data.edgeType === "string" && + GRAPH_EDGE_TYPES.has(data.edgeType) && + "isHighlighted" in data && + typeof data.isHighlighted === "boolean" + ) +} + +/** + * 类型守卫:判断 react-force-graph-2d NodeObject 是否为业务 KpGraphNode。 + * + * NodeObject 带有 `[others: string]: any` 索引签名,运行时需校验关键业务字段 + *(id/name/val/chapterColor/kp/mastery)存在且类型正确。 + */ +export function isKpGraphNode(node: NodeObject): node is KpGraphNode { + return ( + typeof node.id === "string" && + typeof node.name === "string" && + typeof node.val === "number" && + typeof node.chapterColor === "string" && + "kp" in node && + "mastery" in node + ) +} + +/** + * 类型守卫:判断 react-force-graph-2d LinkObject 是否为业务 KpGraphLink。 + * + * 仅校验 edgeType 字段;source/target 在力导向模拟过程中会被库内部 + * 从字符串 id 替换为节点对象引用,故不在此处校验其具体类型。 + */ +export function isKpGraphLink(link: LinkObject): link is KpGraphLink { + return ( + "edgeType" in link && + typeof link.edgeType === "string" && + GRAPH_EDGE_TYPES.has(link.edgeType) + ) +} diff --git a/src/shared/lib/date-utils.ts b/src/shared/lib/date-utils.ts new file mode 100644 index 0000000..643bb46 --- /dev/null +++ b/src/shared/lib/date-utils.ts @@ -0,0 +1,42 @@ +/** + * 日期序列化公共工具 + * + * 审计 v1 P0 修复(G5-005 / S-04 DRY):抽取各 data-access 模块中 + * 重复的 Date → ISO 字符串序列化逻辑,统一为单一来源。 + * + * 命名约定: + * - serializeDate: 接受 null/undefined,返回 string | null(可选字段用) + * - serializeDateRequired: 仅接受 Date,返回 string(必填字段用) + * + * 使用场景: + * - data-access 层 mapXxx 函数将 DB 行(Date)转为 API 响应(ISO 字符串) + * - 任何需要将 Date 序列化为 ISO 字符串的场景 + */ + +/** + * 将 Date 序列化为 ISO 字符串,支持 null/undefined 输入。 + * + * @param date - 日期对象,可为 null/undefined + * @returns ISO 字符串,或 null(当 date 为 null/undefined 时) + * + * @example + * serializeDate(new Date("2026-01-01")) // "2026-01-01T00:00:00.000Z" + * serializeDate(null) // null + * serializeDate(undefined) // null + */ +export function serializeDate(date: Date | null | undefined): string | null { + return date ? date.toISOString() : null +} + +/** + * 将 Date 序列化为 ISO 字符串(必填版本,不接受 null)。 + * + * @param date - 日期对象,必须为有效 Date 实例 + * @returns ISO 字符串 + * + * @example + * serializeDateRequired(new Date("2026-01-01")) // "2026-01-01T00:00:00.000Z" + */ +export function serializeDateRequired(date: Date): string { + return date.toISOString() +}