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
This commit is contained in:
17
src/modules/attendance/lib/type-guards.ts
Normal file
17
src/modules/attendance/lib/type-guards.ts
Normal file
@@ -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)
|
||||
)
|
||||
}
|
||||
143
src/modules/classes/lib/admin-class-mappers.ts
Normal file
143
src/modules/classes/lib/admin-class-mappers.ts
Normal file
@@ -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<classId, Map<ClassSubject, TeacherOption | null>>`。
|
||||
* 提取自 `getAdminClassesRaw` / `getGradeManagedClassesRaw` 中重复实现。
|
||||
*
|
||||
* @param subjectRows - DB 查询返回的 subject teacher 行
|
||||
* @returns classId → (ClassSubject → 任课教师 or null) 映射
|
||||
*/
|
||||
export function buildSubjectsByClassIdMap(
|
||||
subjectRows: readonly SubjectTeacherRow[],
|
||||
): Map<string, Map<ClassSubject, TeacherOption | null>> {
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
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<ClassSubject, TeacherOption | null>()
|
||||
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<string, Map<ClassSubject, TeacherOption | null>>,
|
||||
): 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
|
||||
}
|
||||
73
src/modules/elective/lib/lottery.ts
Normal file
73
src/modules/elective/lib/lottery.ts
Normal file
@@ -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<T>(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<T extends LotterySelectable>(
|
||||
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 }
|
||||
}
|
||||
101
src/modules/elective/lib/schedule-conflict.ts
Normal file
101
src/modules/elective/lib/schedule-conflict.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 选课时间冲突检测纯函数(A-02 修复:从 data-access-operations.ts 抽离)。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 纯函数,无 DB 访问、无副作用,便于单元测试
|
||||
* - data-access 层查询 DB 获取 schedule 字符串后,调用本模块函数完成解析与冲突判定
|
||||
* - 支持中英文星期、多时段、多种分隔符
|
||||
*/
|
||||
|
||||
/**
|
||||
* 星期字符串归一化映射(纯函数,便于测试)。
|
||||
* 支持中英文全称与缩写,统一映射为 1-7 数字字符串。
|
||||
*/
|
||||
const DAY_NORMALIZE_MAP: Readonly<Record<string, string>> = 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
|
||||
}
|
||||
80
src/modules/exams/lib/type-guards.ts
Normal file
80
src/modules/exams/lib/type-guards.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* exams 模块通用类型守卫集合。
|
||||
*
|
||||
* 用于消除 `as Record<string, unknown>` / `as { text?: string }` 等违规断言,
|
||||
* 将 unknown 收窄逻辑集中到本文件,供 ai-pipeline、components、editor 等子目录复用。
|
||||
*/
|
||||
|
||||
import type { JSONContent } from "@tiptap/react"
|
||||
|
||||
// --- 通用 Record 守卫 ---
|
||||
|
||||
/** 判断值是否为非 null 对象(即 Record<string, unknown>)。消除 as Record<string, unknown> 断言。 */
|
||||
export function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
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"
|
||||
)
|
||||
}
|
||||
389
src/modules/grades/data-access-analytics-class.ts
Normal file
389
src/modules/grades/data-access-analytics-class.ts
Normal file
@@ -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<ClassComparisonItem[]> => {
|
||||
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<string, typeof allRows>()
|
||||
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<ClassComparisonResult> => {
|
||||
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<SubjectComparisonItem[]> => {
|
||||
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<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
const bySubject = new Map<string, { name: string; scores: number[] }>()
|
||||
|
||||
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<GradeDistributionResult> => {
|
||||
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<GradeDistributionByGradeResult> => {
|
||||
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<string, typeof rows>()
|
||||
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"],
|
||||
})
|
||||
135
src/modules/grades/data-access-analytics-overview.ts
Normal file
135
src/modules/grades/data-access-analytics-overview.ts
Normal file
@@ -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<SchoolWideGradeSummary> => {
|
||||
// 管理员权限校验:仅 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<typeof r.stats> } => 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"],
|
||||
})
|
||||
261
src/modules/grades/data-access-analytics-student.ts
Normal file
261
src/modules/grades/data-access-analytics-student.ts
Normal file
@@ -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<GradeDistributionWithPosition | null> => {
|
||||
// 仅学生(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<StudentGrowthArchiveResult | null> => {
|
||||
// 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<string, { name: string; startDate: Date }>()
|
||||
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<typeof r> => 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"],
|
||||
})
|
||||
134
src/modules/grades/data-access-analytics-trend.ts
Normal file
134
src/modules/grades/data-access-analytics-trend.ts
Normal file
@@ -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<GradeTrendResult | null> => {
|
||||
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<Array<{ id: string; title: string }>> => {
|
||||
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<string>()
|
||||
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"],
|
||||
})
|
||||
18
src/modules/invitation-codes/lib/type-guards.ts
Normal file
18
src/modules/invitation-codes/lib/type-guards.ts
Normal file
@@ -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
|
||||
}
|
||||
36
src/modules/leave-requests/lib/type-guards.ts
Normal file
36
src/modules/leave-requests/lib/type-guards.ts
Normal file
@@ -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
|
||||
}
|
||||
31
src/modules/lesson-preparation/lib/status-mappers.ts
Normal file
31
src/modules/lesson-preparation/lib/status-mappers.ts
Normal file
@@ -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";
|
||||
}
|
||||
81
src/modules/messaging/data-access-bulk.ts
Normal file
81
src/modules/messaging/data-access-bulk.ts
Normal file
@@ -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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
505
src/modules/messaging/data-access-core.ts
Normal file
505
src/modules/messaging/data-access-core.ts
Normal file
@@ -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<Map<string, string>> {
|
||||
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<string, string>): 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<PaginatedResult<Message>> => {
|
||||
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<Message | null> => {
|
||||
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<Message[]> => {
|
||||
// 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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 查询当前星标状态
|
||||
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<number> => {
|
||||
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<RecipientOption[]> => {
|
||||
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<RecipientOption[]> => {
|
||||
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<RecipientOption[]> => {
|
||||
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<RecipientOption[]> => {
|
||||
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<RecipientOption[]> => {
|
||||
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<DataScope["type"], MessagingRoleConfig> = {
|
||||
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<RecipientOption[]> => {
|
||||
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<boolean> {
|
||||
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<Message | null> {
|
||||
const message = await getMessageById(id, userId)
|
||||
if (!message) return null
|
||||
|
||||
// 自动标记已读:仅当当前用户为接收方且消息未读时
|
||||
if (!message.isRead && message.receiverId === userId) {
|
||||
await markMessageAsRead(id, userId)
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
68
src/modules/messaging/data-access-group.ts
Normal file
68
src/modules/messaging/data-access-group.ts
Normal file
@@ -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<SendGroupMessageResult> {
|
||||
// 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 }
|
||||
}
|
||||
280
src/modules/messaging/data-access-reports.ts
Normal file
280
src/modules/messaging/data-access-reports.ts
Normal file
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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<MessageReport[]> {
|
||||
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<UserBlock[]> {
|
||||
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<MessageAttachment[]> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
241
src/modules/messaging/data-access-templates.ts
Normal file
241
src/modules/messaging/data-access-templates.ts
Normal file
@@ -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<string, string>,
|
||||
): 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<MessageDraft[]> => {
|
||||
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<string> {
|
||||
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<void> {
|
||||
await db
|
||||
.delete(messageDrafts)
|
||||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||||
}
|
||||
|
||||
export async function getMessageDraftById(id: string, userId: string): Promise<MessageDraft | null> {
|
||||
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<string, string>()
|
||||
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<MessageTemplate[]> => {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db
|
||||
.delete(messageTemplates)
|
||||
.where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId)))
|
||||
}
|
||||
12
src/modules/notifications/lib/type-guards.ts
Normal file
12
src/modules/notifications/lib/type-guards.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* notifications 模块类型守卫
|
||||
*/
|
||||
|
||||
/** 判断值是否为字符串字典(Record<string, string>) */
|
||||
export function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
|
||||
for (const v of Object.values(value as Record<string, unknown>)) {
|
||||
if (typeof v !== "string") return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
97
src/modules/parent/actions.ts
Normal file
97
src/modules/parent/actions.ts
Normal file
@@ -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<ParentChildRelation[]> {
|
||||
const ctx = await requireAuth()
|
||||
return getChildren(ctx.userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取子女基本信息(含班级/年级)。
|
||||
* 权限:必须通过 verifyParentChildRelation 校验家长与子女关系。
|
||||
*/
|
||||
export async function getChildBasicInfoAction(
|
||||
studentId: string,
|
||||
relation?: string | null,
|
||||
): Promise<ChildBasicInfo | null> {
|
||||
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<ChildDashboardData | null> {
|
||||
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<ParentDashboardData> {
|
||||
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<string | null> {
|
||||
const ctx = await requireAuth()
|
||||
return verifyParentChildRelation(studentId, ctx.userId)
|
||||
}
|
||||
158
src/modules/scheduling/lib/schedule-validation.ts
Normal file
158
src/modules/scheduling/lib/schedule-validation.ts
Normal file
@@ -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 }
|
||||
}
|
||||
41
src/modules/school/data-access-classrooms.ts
Normal file
41
src/modules/school/data-access-classrooms.ts
Normal file
@@ -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<ClassroomOption[]> => {
|
||||
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"],
|
||||
})
|
||||
64
src/modules/school/data-access-departments.ts
Normal file
64
src/modules/school/data-access-departments.ts
Normal file
@@ -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<DepartmentListItem[]> => {
|
||||
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<void> {
|
||||
await db.insert(departments).values({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateDepartment(
|
||||
id: string,
|
||||
data: DepartmentUpdateData
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(departments)
|
||||
.set({ name: data.name, description: data.description })
|
||||
.where(eq(departments.id, id))
|
||||
}
|
||||
|
||||
export async function deleteDepartment(id: string): Promise<void> {
|
||||
await db.delete(departments).where(eq(departments.id, id))
|
||||
}
|
||||
453
src/modules/school/data-access-grades.ts
Normal file
453
src/modules/school/data-access-grades.ts
Normal file
@@ -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<GradeListItem[]> {
|
||||
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<GradeListItem[]> {
|
||||
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<string, StaffOption>()
|
||||
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<GradeListItem[]> => {
|
||||
return await fetchGradesWithHeads()
|
||||
}
|
||||
export const getGrades = cacheFn(getGradesRaw, {
|
||||
tags: ["school"],
|
||||
ttl: 300,
|
||||
keyParts: ["school", "getGrades"],
|
||||
})
|
||||
|
||||
export const getGradesForStaffRaw = async (staffId: string): Promise<GradeListItem[]> => {
|
||||
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<GradeListItem[]> => {
|
||||
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<GradeOption[]> => {
|
||||
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<string | null> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<string | null> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<GradeOverviewStats[]> => {
|
||||
// 动态导入 classes 模块避免循环依赖
|
||||
const { getAdminClasses } = await import("@/modules/classes/data-access")
|
||||
const allClasses = await getAdminClasses()
|
||||
|
||||
// 按年级分组统计班级数和学生数
|
||||
const statsByGrade = new Map<string, { classCount: number; studentCount: number; teacherIds: Set<string> }>()
|
||||
|
||||
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<string>() }
|
||||
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"],
|
||||
})
|
||||
160
src/modules/school/data-access-schools.ts
Normal file
160
src/modules/school/data-access-schools.ts
Normal file
@@ -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<SchoolListItem[]> => {
|
||||
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<void> {
|
||||
await db.insert(schools).values({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateSchool(
|
||||
id: string,
|
||||
data: SchoolUpdateData
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schools)
|
||||
.set({ name: data.name, code: data.code })
|
||||
.where(eq(schools.id, id))
|
||||
}
|
||||
|
||||
export async function deleteSchool(id: string): Promise<void> {
|
||||
await db.delete(schools).where(eq(schools.id, id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 员工选项(教师 + 管理员)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getStaffOptionsRaw = async (): Promise<StaffOption[]> => {
|
||||
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<OrgTreeNode[]> => {
|
||||
const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()])
|
||||
|
||||
const { getAdminClasses } = await import("@/modules/classes/data-access")
|
||||
const allClasses = await getAdminClasses()
|
||||
|
||||
const classesByGradeId = new Map<string, OrgTreeNode[]>()
|
||||
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<string, OrgTreeNode[]>()
|
||||
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"],
|
||||
})
|
||||
85
src/modules/school/data-access-semesters.ts
Normal file
85
src/modules/school/data-access-semesters.ts
Normal file
@@ -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<AcademicYearListItem[]> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db.delete(academicYears).where(eq(academicYears.id, id))
|
||||
}
|
||||
97
src/modules/school/data-access-subjects.ts
Normal file
97
src/modules/school/data-access-subjects.ts
Normal file
@@ -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<SubjectOption[]> => {
|
||||
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<string | null> => {
|
||||
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<Map<string, string | null>> => {
|
||||
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<string, string | null>()
|
||||
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"],
|
||||
})
|
||||
156
src/modules/school/lib/school-permissions.ts
Normal file
156
src/modules/school/lib/school-permissions.ts
Normal file
@@ -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<SchoolListItem[]> => {
|
||||
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<GradeListItem[]> => {
|
||||
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"],
|
||||
})
|
||||
26
src/modules/settings/lib/type-guards.ts
Normal file
26
src/modules/settings/lib/type-guards.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { FileUploadResult } from "@/modules/files/types"
|
||||
|
||||
/** 判断值是否为普通对象(非数组、非 null) */
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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"
|
||||
)
|
||||
}
|
||||
19
src/modules/standards/lib/type-guards.ts
Normal file
19
src/modules/standards/lib/type-guards.ts
Normal file
@@ -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
|
||||
}
|
||||
122
src/modules/textbooks/lib/type-guards.ts
Normal file
122
src/modules/textbooks/lib/type-guards.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 教材模块类型守卫集合。
|
||||
*
|
||||
* 替代 components/* 中针对 React Flow `node.data`(Record<string, unknown>)
|
||||
* 与 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<string> = 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<GraphLayoutNodeData>` 的 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)
|
||||
)
|
||||
}
|
||||
42
src/shared/lib/date-utils.ts
Normal file
42
src/shared/lib/date-utils.ts
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user