Files
NextEdu/src/modules/classes/actions-invitations.ts
SpecialX c766951374 feat(school,classes): 实现 P2 长期问题全量改进项
P2-2: 新增 OrgTreeNav 组件(学校→年级→班级三级树形导航,支持搜索过滤/选中高亮/展开折叠)

P2-3: 新增 promoteGradesAction 年级升级功能(中文数字/阿拉伯数字识别,按 order 降序避免冲突)

P2-4: 新增 bulkEnrollStudentsAction(CSV 批量导入学生)+ bulkAssignSubjectTeachersAction(CSV 批量分配教师)

P2-5: 为 department/academicYear/grade 的 9 个 CRUD Action 补充 logAudit 审计日志

同步更新架构图文档 004/005
2026-06-23 08:55:21 +08:00

503 lines
17 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.
"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
}
}
/**
* P2-4 批量导入学生:解析 CSV每行 name,email 或仅 email逐个调用 enrollStudentByEmail。
* 用于开学季批量注册,提升配置效率。
*/
export async function bulkEnrollStudentsAction(
classId: string,
prevState: ActionState<{ imported: number; failed: number; errors: string[] }> | undefined,
formData: FormData
): Promise<ActionState<{ imported: number; failed: number; errors: string[] }>> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
const csvText = String(formData.get("csv") ?? "").trim()
if (!csvText) {
return { success: false, message: "CSV data is required" }
}
// 解析 CSV每行一个邮箱格式 name,email 或仅 email
const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0)
const entries: Array<{ name?: string; email: string }> = []
for (const line of lines) {
const parts = line.split(",").map((p) => p.trim())
if (parts.length === 1) {
entries.push({ email: parts[0] })
} else if (parts.length >= 2) {
entries.push({ name: parts[0], email: parts[1] })
}
}
if (entries.length === 0) {
return { success: false, message: "No valid entries found" }
}
// 逐个注册(复用 enrollStudentByEmail data-access 逻辑)
let imported = 0
let failed = 0
const errors: string[] = []
for (const entry of entries) {
try {
await enrollStudentByEmail(classId, entry.email)
imported += 1
} catch (error) {
failed += 1
const msg = error instanceof Error ? error.message : "Unknown error"
errors.push(`${entry.email}: ${msg}`)
}
}
revalidatePath("/teacher/classes/students")
revalidatePath("/admin/school/classes")
return {
success: true,
message: `Imported ${imported} students, ${failed} failed`,
data: { imported, failed, errors },
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Failed to bulk enroll students" }
}
}
/**
* P2-4 批量分配教师:解析 CSV每行 className,subject,teacherEmail
* 当前为简化实现:需按名称查找班级、按邮箱查找教师后调用 setClassSubjectTeachers
* 查找逻辑待后续完善,暂记录为失败。
*/
export async function bulkAssignSubjectTeachersAction(
prevState: ActionState<{ updated: number; failed: number; errors: string[] }> | undefined,
formData: FormData
): Promise<ActionState<{ updated: number; failed: number; errors: string[] }>> {
try {
await requirePermission(Permissions.CLASS_UPDATE)
const csvText = String(formData.get("csv") ?? "").trim()
if (!csvText) {
return { success: false, message: "CSV data is required" }
}
// 解析 CSV格式 className,subject,teacherEmail
const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0)
const entries: Array<{ className: string; subject: string; teacherEmail: string }> = []
for (const line of lines) {
const parts = line.split(",").map((p) => p.trim())
if (parts.length >= 3) {
entries.push({ className: parts[0], subject: parts[1], teacherEmail: parts[2] })
}
}
if (entries.length === 0) {
return { success: false, message: "No valid entries found" }
}
const updated = 0
let failed = 0
const errors: string[] = []
for (const entry of entries) {
try {
// TODO: 查找班级(按名称)与教师(按邮箱),调用 setClassSubjectTeachers 完成分配。
// 当前版本暂未实现按名称/邮箱的查找逻辑,记录为失败。
failed += 1
errors.push(`${entry.className}/${entry.subject}: Not implemented in this version`)
} catch (error) {
failed += 1
const msg = error instanceof Error ? error.message : "Unknown error"
errors.push(`${entry.className}/${entry.subject}: ${msg}`)
}
}
revalidatePath("/admin/school/classes")
return {
success: true,
message: `Updated ${updated} assignments, ${failed} failed`,
data: { updated, failed, errors },
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Failed to bulk assign teachers" }
}
}