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:
@@ -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"
|
||||
|
||||
/**
|
||||
* 校验当前用户对考勤记录的归属权限。
|
||||
* - admin(scope=all):直接放行
|
||||
* - teacher(scope=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" }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
@@ -15,44 +16,45 @@ interface AttendanceStatsCardsProps {
|
||||
}
|
||||
|
||||
export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
|
||||
const t = useTranslations("attendance")
|
||||
const cards = [
|
||||
{
|
||||
title: "总记录数",
|
||||
title: t("stats.totalRecords"),
|
||||
value: stats.totalRecords,
|
||||
icon: FileText,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "出勤",
|
||||
title: t("stats.present"),
|
||||
value: stats.presentCount,
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
title: "缺勤",
|
||||
title: t("stats.absent"),
|
||||
value: stats.absentCount,
|
||||
icon: XCircle,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
{
|
||||
title: "迟到",
|
||||
title: t("stats.late"),
|
||||
value: stats.lateCount,
|
||||
icon: Clock,
|
||||
color: "text-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
{
|
||||
title: "早退",
|
||||
title: t("stats.earlyLeave"),
|
||||
value: stats.earlyLeaveCount,
|
||||
icon: LogOut,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-500/10",
|
||||
},
|
||||
{
|
||||
title: "出勤率",
|
||||
title: t("stats.attendanceRate"),
|
||||
value: `${stats.attendanceRate}%`,
|
||||
icon: Users,
|
||||
color: "text-primary",
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
attendanceRecords,
|
||||
attendanceRules,
|
||||
classes,
|
||||
classEnrollments,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
@@ -205,17 +205,24 @@ export async function deleteAttendanceRecord(id: string): Promise<void> {
|
||||
await db.delete(attendanceRecords).where(eq(attendanceRecords.id, id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考勤记录的 classId(用于资源归属校验)。
|
||||
* 返回 null 表示记录不存在。
|
||||
*/
|
||||
export async function getAttendanceRecordClassId(id: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ classId: attendanceRecords.classId })
|
||||
.from(attendanceRecords)
|
||||
.where(eq(attendanceRecords.id, id))
|
||||
.limit(1)
|
||||
return row?.classId ?? null
|
||||
}
|
||||
|
||||
export async function getClassStudentsForAttendance(
|
||||
classId: string
|
||||
): Promise<Array<{ id: string; name: string; email: string }>> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(users.name))
|
||||
|
||||
return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email }))
|
||||
// 通过 classes data-access 获取班级学生,避免跨模块直查 classEnrollments 表
|
||||
return getClassActiveStudentsWithInfo(classId)
|
||||
}
|
||||
|
||||
export async function getAttendanceRules(classId?: string): Promise<AttendanceRule[]> {
|
||||
@@ -288,15 +295,38 @@ export async function getAttendanceStats(params: {
|
||||
classId?: string
|
||||
date?: string
|
||||
}): Promise<AttendanceOverviewStats> {
|
||||
// 简化实现:基于已有查询统计
|
||||
const records = await getAttendanceRecords(params)
|
||||
const items = records.items
|
||||
const total = items.length
|
||||
const present = items.filter((r) => r.status === "present").length
|
||||
const absent = items.filter((r) => r.status === "absent").length
|
||||
const late = items.filter((r) => r.status === "late").length
|
||||
const earlyLeave = items.filter((r) => r.status === "early_leave").length
|
||||
const excused = items.filter((r) => r.status === "excused").length
|
||||
// 直接使用 SQL 聚合查询,避免分页截断导致统计失真(P0 修复)
|
||||
const conditions: SQL[] = []
|
||||
|
||||
const scopeFilter = buildScopeFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
|
||||
}
|
||||
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
||||
if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date)))
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
totalRecords: count(),
|
||||
presentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
absentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
lateCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeaveCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
excusedCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
|
||||
const total = Number(row?.totalRecords ?? 0)
|
||||
const present = Number(row?.presentCount ?? 0)
|
||||
const absent = Number(row?.absentCount ?? 0)
|
||||
const late = Number(row?.lateCount ?? 0)
|
||||
const earlyLeave = Number(row?.earlyLeaveCount ?? 0)
|
||||
const excused = Number(row?.excusedCount ?? 0)
|
||||
|
||||
return {
|
||||
totalRecords: total,
|
||||
presentCount: present,
|
||||
|
||||
Reference in New Issue
Block a user