refactor(school,classes): 完成 school/grade/class 审计全量改进项

P0-1/P0-2: 删除 grade-management 死模块,年级 CRUD 统一由 school 模块负责

P0-3: classes/actions.ts 从 974 行拆分为 6 个职责文件 + barrel re-export

P0-5: 13 个页面 i18n 全量接入(grades/departments/academic-year/classes/insights)

P1-1: 角色硬编码改为 hasAdminScope/hasTeacherScope/hasStudentScope 基于 dataScope.type

P1-3: 新增 SchoolErrorBoundary + SchoolListSkeleton/SchoolCardSkeleton,4 个页面包裹 Error Boundary

P1-4: classes/types.ts 跨领域类型添加归属决策注释

P1-5: schools-view.tsx 拆分为组合模式(SchoolFormDialog + SchoolDeleteDialog + SchoolListToolbar)

P1-6: 新增 getSchoolsForUser/getGradesForUser 权限感知查询函数

P2-1: 抽取 useSchoolData hook,对话框状态管理与 UI 分离

同步更新架构图文档 004/005
This commit is contained in:
SpecialX
2026-06-22 18:54:01 +08:00
parent 97e59b95a1
commit 15aa84b72c
29 changed files with 2267 additions and 1380 deletions

View File

@@ -0,0 +1,377 @@
"use server"
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 {
enrollStudentByEmail,
enrollStudentByInvitationCode,
enrollTeacherByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setStudentEnrollmentStatus,
} from "./data-access"
import {
EnrollStudentByEmailSchema,
} from "./schema"
import { hasTeacherScope, hasStudentScope } from "./actions-shared"
export async function enrollStudentByEmailAction(
classId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
const parsed = EnrollStudentByEmailSchema.safeParse({
classId,
email: formData.get("email"),
})
if (!parsed.success) {
return { success: false, message: "Please select a class and provide student email" }
}
try {
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student added successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
export async function joinClassByInvitationCodeAction(
prevState: ActionState<{ classId: string }> | null,
formData: FormData
): Promise<ActionState<{ classId: string }>> {
try {
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
const code = formData.get("code")
if (typeof code !== "string" || code.trim().length === 0) {
return { success: false, message: "Invitation code is required" }
}
// v3rate limit 防爆破10 次/5 分钟,按 userId
const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit")
const rlKey = rateLimitKey("class-join", ctx.userId)
const rlResult = rateLimit({
key: rlKey,
limit: 10,
windowMs: 5 * 60 * 1000,
})
if (!rlResult.success) {
return { success: false, message: "Too many attempts, please try again later" }
}
// P1-1: 使用 dataScope 替代 ctx.roles.includes("teacher") 硬编码
const isTeacher = hasTeacherScope(ctx)
const subjectValue = formData.get("subject")
const subject = isTeacher && typeof subjectValue === "string" ? subjectValue.trim() : null
if (isTeacher && (!subject || subject.length === 0)) {
return { success: false, message: "Subject is required" }
}
try {
const classId = isTeacher
? await enrollTeacherByInvitationCode(ctx.userId, code, subject)
: await enrollStudentByInvitationCode(ctx.userId, code)
// 成功后重置 rate limit
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
resetRateLimit(rlKey)
// 审计日志
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.consume",
module: "classes",
targetId: classId,
targetType: "class",
detail: {
code: String(code).trim().toUpperCase(),
userId: ctx.userId,
// P1-1: 使用 dataScope 推断角色名,避免硬编码
role: hasStudentScope(ctx) ? "student" : "teacher",
subject,
},
})
if (hasStudentScope(ctx)) {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
} else {
revalidatePath("/teacher/classes/my")
}
revalidatePath("/profile")
return { success: true, message: "Joined class successfully", data: { classId } }
} catch (error) {
// 审计日志:加入失败
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.consume_failed",
module: "classes",
targetId: String(code).trim().toUpperCase(),
targetType: "invitation_code",
detail: {
userId: ctx.userId,
reason: error instanceof Error ? error.message : "unknown",
},
status: "failure",
})
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await ensureClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code ready", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await regenerateClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code updated", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
/**
* v3 新增:生成自定义邀请码(支持有效期/次数/备注)。
* 对标 Google Classroom / 钉钉教育:管理员/教师可为班级生成带有效期与次数限制的邀请码。
*
* 权限CLASS_ENROLL沿用现有权限点避免过度拆分
* 审计:调用 logAudit 记录生成操作
*/
export async function createClassInvitationCodeAction(
prevState: ActionState<{ code: string; id: string }> | null,
formData: FormData
): Promise<ActionState<{ code: string; id: string }>> {
try {
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
const classId = String(formData.get("classId") ?? "").trim()
if (!classId) {
return { success: false, message: "Missing class id" }
}
const expiresInHoursRaw = formData.get("expiresInHours")
const maxUsesRaw = formData.get("maxUses")
const note = String(formData.get("note") ?? "").trim() || null
const expiresInHours =
expiresInHoursRaw && String(expiresInHoursRaw).trim() !== ""
? Number(expiresInHoursRaw)
: null
const maxUses =
maxUsesRaw && String(maxUsesRaw).trim() !== ""
? Number(maxUsesRaw)
: null
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
return { success: false, message: "Invalid expiresInHours" }
}
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
return { success: false, message: "Invalid maxUses" }
}
try {
const { createInvitationCode } = await import("./data-access-invitations")
const record = await createInvitationCode(classId, ctx.userId, {
expiresInHours,
maxUses,
note,
})
// 审计日志
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.create",
module: "classes",
targetId: classId,
targetType: "class",
detail: {
codeId: record.id,
code: record.code,
expiresInHours,
maxUses,
note,
},
})
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
revalidatePath(`/admin/school/classes`)
return {
success: true,
message: "Invitation code generated",
data: { code: record.code, id: record.id },
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Failed to generate code",
}
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
/**
* v3 新增:撤销邀请码(软删除)。
*/
export async function revokeClassInvitationCodeAction(
prevState: ActionState<null> | null,
formData: FormData
): Promise<ActionState<null>> {
try {
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
const codeId = String(formData.get("codeId") ?? "").trim()
if (!codeId) {
return { success: false, message: "Missing code id" }
}
try {
const { revokeInvitationCode } = await import("./data-access-invitations")
await revokeInvitationCode(codeId, ctx.userId)
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.revoke",
module: "classes",
targetId: codeId,
targetType: "invitation_code",
detail: { revokedBy: ctx.userId },
})
revalidatePath("/teacher/classes/my")
revalidatePath(`/admin/school/classes`)
return { success: true, message: "Invitation code revoked" }
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Failed to revoke code",
}
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
/**
* v3 新增:列出班级所有邀请码(管理端列表用)。
*/
export async function listClassInvitationCodesAction(
classId: string
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const { listClassInvitationCodes } = await import("./data-access-invitations")
const codes = await listClassInvitationCodes(classId)
return {
success: true,
data: {
codes: codes.map((c) => ({
id: c.id,
code: c.code,
status: c.status,
maxUses: c.maxUses,
usedCount: c.usedCount,
expiresAt: c.expiresAt?.toISOString() ?? null,
createdAt: c.createdAt.toISOString(),
revokedAt: c.revokedAt?.toISOString() ?? null,
note: c.note,
})),
},
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Failed to list codes",
}
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
export async function setStudentEnrollmentStatusAction(
classId: string,
studentId: string,
status: "active" | "inactive"
): Promise<ActionState> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
if (!classId?.trim() || !studentId?.trim()) {
return { success: false, message: "Missing enrollment info" }
}
try {
await setStudentEnrollmentStatus(classId, studentId, status)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}