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:
SpecialX
2026-07-07 16:17:24 +08:00
parent 4e6d397d8e
commit b333cda8c5
31 changed files with 4125 additions and 0 deletions

View 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)
)
}

View 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
}

View 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 }
}

View 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
}

View 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 的 UniqueIdentifierstring | 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"
)
}

View 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"],
})

View 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"],
})

View 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"],
})

View 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"],
})

View 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
}

View 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
}

View 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";
}

View 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)))
}
}

View 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"
// ---------------------------------------------------------------------------
// 共享 helpersresolveUserNames 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
}

View 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 }
}

View 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,
}))
}

View File

@@ -0,0 +1,241 @@
import "server-only"
/**
* 消息草稿 + 快捷回复模板数据访问层
*
* 审计 v1 P0 修复G4-003 / S-01从 data-access.ts 拆分。
*
* 职责:
* - V2-P2-13c: 消息草稿 CRUDmessage_drafts 表),含 P2-10 乐观锁
* - P2-6: 快捷回复模板 CRUDmessage_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: 消息草稿 CRUDmessage_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)))
}

View 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
}

View 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)
}

View 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 }
}

View 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"],
})

View 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))
}

View 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"
// ---------------------------------------------------------------------------
// 共享 helperS-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"],
})

View 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"],
})

View 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))
}

View 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"],
})

View 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"],
})

View 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"
)
}

View 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
}

View 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)
)
}
/**
* 类型守卫:判断未知值是否为合法的 GraphNodeDataReact 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"
)
}
/**
* 类型守卫:判断未知值是否为合法的 GraphLayoutNodeDatadagre 布局节点 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"
)
}
/**
* 类型守卫:判断未知值是否为合法的 GraphEdgeDataReact 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)
)
}

View 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()
}