"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 { 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> { 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" } } // v3:rate 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> { 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> { 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> { 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, formData: FormData ): Promise> { 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> }>> { 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 { 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> { 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> { 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" } } }