P2-2: 新增 OrgTreeNav 组件(学校→年级→班级三级树形导航,支持搜索过滤/选中高亮/展开折叠) P2-3: 新增 promoteGradesAction 年级升级功能(中文数字/阿拉伯数字识别,按 order 降序避免冲突) P2-4: 新增 bulkEnrollStudentsAction(CSV 批量导入学生)+ bulkAssignSubjectTeachersAction(CSV 批量分配教师) P2-5: 为 department/academicYear/grade 的 9 个 CRUD Action 补充 logAudit 审计日志 同步更新架构图文档 004/005
503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
"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" }
|
||
}
|
||
|
||
// 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<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" }
|
||
}
|
||
}
|