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

View File

@@ -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",

View File

@@ -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,

View File

@@ -221,6 +221,22 @@ export const getActiveStudentIdsByClassId = async (classId: string): Promise<str
return rows.map((r) => r.studentId)
}
/**
* 获取班级所有活跃学生基本信息id/name/email按姓名升序。
* 供跨模块调用使用(如考勤点名),避免直接查询 classEnrollments 表。
*/
export const getClassActiveStudentsWithInfo = async (
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 }))
}
/**
* 获取教师在一个班级所教的科目 ID 列表。
* 参数顺序为 (classId, teacherId),供跨模块调用使用。

View File

@@ -54,6 +54,24 @@ const requireCourseId = (formData: FormData): string => {
return id
}
/**
* 校验当前用户对课程的管理权限(资源归属校验)。
* - adminscope=all直接放行
* - teacher其他 scope必须为课程的授课教师
*/
async function assertCourseOwnership(
courseId: string,
ctx: Awaited<ReturnType<typeof requirePermission>>
): Promise<{ ok: boolean; message?: string }> {
if (ctx.dataScope.type === "all") return { ok: true }
const course = await getElectiveCourseById(courseId)
if (!course) return { ok: false, message: "Course not found" }
if (course.teacherId !== ctx.userId) {
return { ok: false, message: "You do not own this course" }
}
return { ok: true }
}
export async function createElectiveCourseAction(
prevState: ActionState<string> | null,
formData: FormData
@@ -97,9 +115,12 @@ export async function updateElectiveCourseAction(
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const existing = await getElectiveCourseById(id)
if (!existing) return { success: false, message: "Course not found" }
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const ownership = await assertCourseOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
const parsed = UpdateElectiveCourseSchema.safeParse({
name: formData.get("name") || undefined,
@@ -138,11 +159,13 @@ export async function deleteElectiveCourseAction(
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const id = requireCourseId(formData)
const existing = await getElectiveCourseById(id)
if (!existing) return { success: false, message: "Course not found" }
const ownership = await assertCourseOwnership(id, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
await deleteElectiveCourse(id)
revalidateElectivePaths()
@@ -157,8 +180,14 @@ export async function openSelectionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
const ownership = await assertCourseOwnership(courseId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
await openSelection(courseId)
revalidateElectivePaths(courseId)
return { success: true, message: "Selection opened" }
@@ -172,8 +201,14 @@ export async function closeSelectionAction(
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
const ownership = await assertCourseOwnership(courseId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
await closeSelection(courseId)
revalidateElectivePaths(courseId)
return { success: true, message: "Selection closed" }
@@ -187,7 +222,7 @@ export async function runLotteryAction(
formData: FormData
): Promise<ActionState<{ enrolled: number; waitlist: number }>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const parsed = RunLotterySchema.safeParse({
courseId: formData.get("courseId"),
})
@@ -198,6 +233,12 @@ export async function runLotteryAction(
errors: parsed.error.flatten().fieldErrors,
}
}
const ownership = await assertCourseOwnership(parsed.data.courseId, ctx)
if (!ownership.ok) {
return { success: false, message: ownership.message ?? "Ownership check failed" }
}
const result = await runLottery(parsed.data.courseId)
revalidateElectivePaths(parsed.data.courseId)
return {

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
@@ -18,7 +19,7 @@ import {
} from "@/shared/components/ui/select"
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
import type { ElectiveCourseWithDetails } from "../types"
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
type Mode = "create" | "edit"
@@ -27,6 +28,9 @@ interface Option {
name: string
}
const isSelectionMode = (v: string): v is ElectiveSelectionMode =>
v === "fcfs" || v === "lottery"
export function ElectiveCourseForm({
mode,
course,
@@ -43,12 +47,13 @@ export function ElectiveCourseForm({
backHref?: string
}) {
const router = useRouter()
const t = useTranslations("elective")
const [isWorking, setIsWorking] = useState(false)
const [subjectId, setSubjectId] = useState(course?.subjectId ?? "")
const [gradeId, setGradeId] = useState(course?.gradeId ?? "")
const [teacherId, setTeacherId] = useState(course?.teacherId ?? "")
const [selectionMode, setSelectionMode] = useState(course?.selectionMode ?? "fcfs")
const [selectionMode, setSelectionMode] = useState<ElectiveSelectionMode>(course?.selectionMode ?? "fcfs")
const handleSubmit = async (formData: FormData) => {
setIsWorking(true)
@@ -200,14 +205,19 @@ export function ElectiveCourseForm({
</div>
<div className="grid gap-2">
<Label>Selection Mode</Label>
<Select value={selectionMode} onValueChange={(v) => setSelectionMode(v as "fcfs" | "lottery")}>
<Label>{t("fields.selectionMode")}</Label>
<Select
value={selectionMode}
onValueChange={(v) => {
if (isSelectionMode(v)) setSelectionMode(v)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fcfs">First Come First Served</SelectItem>
<SelectItem value="lottery">Lottery</SelectItem>
<SelectItem value="fcfs">{t("selectionMode.fcfs")}</SelectItem>
<SelectItem value="lottery">{t("selectionMode.lottery")}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="selectionMode" value={selectionMode} />

View File

@@ -2,6 +2,7 @@
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
@@ -10,6 +11,7 @@ import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { usePermission } from "@/shared/hooks/use-permission"
import type { ActionState } from "@/shared/types/action-state"
import { Permissions } from "@/shared/types/permissions"
import {
@@ -37,13 +39,14 @@ export function ElectiveCourseList({
canManage?: boolean
}) {
const router = useRouter()
const t = useTranslations("elective")
const { hasPermission } = usePermission()
const manageResolved = canManage ?? hasPermission(Permissions.ELECTIVE_MANAGE)
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const runAction = async (
action: (prevState: never, formData: FormData) => Promise<{ success: boolean; message?: string }>,
const runAction = async <T,>(
action: (prevState: ActionState<T> | null, formData: FormData) => Promise<ActionState<T>>,
courseId: string,
successMsg: string
) => {
@@ -51,7 +54,7 @@ export function ElectiveCourseList({
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
const res = await action(null as never, formData)
const res = await action(null, formData)
if (res.success) {
toast.success(res.message ?? successMsg)
router.refresh()
@@ -82,13 +85,13 @@ export function ElectiveCourseList({
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{courses.length} course{courses.length === 1 ? "" : "s"}
{courses.length} {t("title.adminList")}
</p>
{manageResolved && createHref ? (
<Button asChild>
<a href={createHref}>
<Plus className="mr-2 h-4 w-4" />
New Course
{t("actions.create")}
</a>
</Button>
) : null}
@@ -96,8 +99,8 @@ export function ElectiveCourseList({
{courses.length === 0 ? (
<EmptyState
title="No elective courses"
description="There are no elective courses available."
title={t("list.empty")}
description={t("list.emptyDescription")}
icon={Plus}
className="h-auto border-none shadow-none"
/>