Files
NextEdu/src/modules/school/data-access-grades.ts
SpecialX b333cda8c5 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
2026-07-07 16:17:24 +08:00

454 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"],
})