feat(attendance,elective): 考勤与选修课模块审计重构 — P0 修复 + i18n + Error Boundary

审计报告:docs/architecture/audit/attendance-elective-audit-report.md

P0 修复:
- attendance: getAttendanceStats 统计失真(仅基于前 20 条记录)改为 SQL 聚合查询
- attendance: getClassStudentsForAttendance 跨模块直查 classEnrollments 改为调用 classes data-access
- attendance: update/delete Action 新增资源归属校验(assertRecordOwnership)
- elective: update/delete/openSelection/closeSelection/runLottery Action 新增资源归属校验(assertCourseOwnership)

i18n 接入:
- 新增 attendance/elective 命名空间(zh-CN + en)
- attendance-stats-cards 接入 useTranslations
- elective-course-list/form 接入 useTranslations

类型安全(P1):
- elective-course-form: 移除 as 断言,改用类型守卫 isSelectionMode
- elective-course-list: 移除 null as never 类型逃逸,改用泛型

Error Boundary:
- 新增 admin/teacher attendance error.tsx
- 新增 admin/student elective error.tsx

架构图同步:
- 004: 修正 attendance/elective/parent 章节的导出函数、文件清单、已知问题
- 005: 修正 actions 的 usedBy(标记无调用方的死代码)、新增 issues 字段、更新依赖矩阵
This commit is contained in:
SpecialX
2026-06-22 16:17:00 +08:00
parent 5d42495480
commit 4833930834
16 changed files with 1431 additions and 48 deletions

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
import {
RecordAttendanceSchema,
@@ -16,6 +17,7 @@ import {
batchCreateAttendanceRecords,
updateAttendanceRecord,
deleteAttendanceRecord,
getAttendanceRecordClassId,
getAttendanceRecords,
getClassAttendanceForDate,
getAttendanceRules,
@@ -27,6 +29,27 @@ import {
} from "./data-access-stats"
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
/**
* 校验当前用户对考勤记录的归属权限。
* - adminscope=all直接放行
* - teacherscope=class_taught必须为记录所属班级的任课教师
* - 其他 scope拒绝学生/家长不应调用写操作)
*/
async function assertRecordOwnership(
recordId: string,
ctx: Awaited<ReturnType<typeof requirePermission>>
): Promise<{ ok: boolean; message?: string }> {
if (ctx.dataScope.type === "all") return { ok: true }
if (ctx.dataScope.type === "class_taught") {
const classId = await getAttendanceRecordClassId(recordId)
if (!classId) return { ok: false, message: "Attendance record not found" }
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
if (!owns) return { ok: false, message: "You do not own this attendance record" }
return { ok: true }
}
return { ok: false, message: "Insufficient permissions" }
}
export async function recordAttendanceAction(
prevState: ActionState<string> | null,
formData: FormData
@@ -101,7 +124,12 @@ export async function updateAttendanceAction(
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ATTENDANCE_MANAGE)
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const ownership = await assertRecordOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
const parsed = UpdateAttendanceSchema.safeParse({
status: formData.get("status") || undefined,
@@ -131,7 +159,13 @@ export async function deleteAttendanceAction(
id: string
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ATTENDANCE_MANAGE)
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
const ownership = await assertRecordOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
await deleteAttendanceRecord(id)
revalidatePath("/teacher/attendance")
return { success: true, message: "Attendance record deleted" }