"use server" import { revalidatePath } from "next/cache" import type { ActionState } from "@/shared/types/action-state" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { z } from "zod" import { createId } from "@paralleldrive/cuid2" import { handleActionError, safeJsonParse, } from "@/shared/lib/action-utils" import { trackExamEvent } from "@/shared/lib/track-event" import { buildExamDescription, deleteExamById, duplicateExam, getExamCreatorId, getExamGrades, getExamPreview, getExamSubjects, persistAiGeneratedExamDraft, persistExamDraft, resolveSubjectGradeNames, updateExamWithQuestions, type ExamModeConfig, } from "./data-access" import { AiGeneratedStructureSchema, AiInsertQuestionSchema, AiQuestionSchema, generateAiCreateDraftFromSource, generateAiPreviewData, regenerateAiQuestionByInstruction, } from "./ai-pipeline" import type { AiGeneratedQuestion, AiGeneratedStructureNode, AiPreviewData, AiRewriteQuestionData, } from "./ai-pipeline" export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline" const ExamCreateSchema = z.object({ title: z.string().min(1), subject: z.string().min(1), grade: z.string().min(1), difficulty: z.coerce.number().int().min(1).max(5), totalScore: z.coerce.number().int().min(1), durationMin: z.coerce.number().int().min(1), scheduledAt: z.string().optional().nullable(), questions: z .array( z.object({ id: z.string(), score: z.coerce.number().int().min(0), }) ) .optional(), }) const getStringValue = (formData: FormData, key: string) => { const value = formData.get(key) return typeof value === "string" ? value : undefined } const getBoolValue = (formData: FormData, key: string, fallback = false): boolean => { const value = formData.get(key) if (typeof value !== "string") return fallback return value === "true" } const parseExamModeConfig = (formData: FormData): ExamModeConfig => { const rawMode = getStringValue(formData, "examMode") const examMode: ExamModeConfig["examMode"] = rawMode === "timed" || rawMode === "proctored" ? rawMode : "homework" const rawDuration = getStringValue(formData, "durationMinutes") const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration)) ? Number(rawDuration) : null const rawGrace = getStringValue(formData, "lateStartGraceMinutes") ?? "0" const parsedGrace = Number(rawGrace) const lateStartGraceMinutes = Number.isFinite(parsedGrace) ? parsedGrace : 0 return { examMode, durationMinutes, shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false), allowLateStart: getBoolValue(formData, "allowLateStart", false), lateStartGraceMinutes, antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false), } } const failState = (message: string, errors?: Record): ActionState => ({ success: false, message, errors, }) const successState = (data: T, message?: string): ActionState => ({ success: true, message, data, }) const invalidFormState = ( error: z.ZodError, options?: { fallbackMessage?: string; useFirstMessage?: boolean } ): ActionState => { const errors = error.flatten().fieldErrors const fallbackMessage = options?.fallbackMessage ?? "Invalid form data" const useFirstMessage = options?.useFirstMessage ?? true const messages = Object.values(errors).flatMap((items) => items ?? []) const firstMessage = messages.find((msg): msg is string => typeof msg === "string" && msg.length > 0) return failState(useFirstMessage ? (firstMessage ?? fallbackMessage) : fallbackMessage, errors) } const prepareExamCreateContext = async (input: { subject: string grade: string difficulty: number totalScore: number durationMin: number scheduledAt?: string | null }) => { const examId = createId() const scheduled = input.scheduledAt || undefined const resolvedNames = await resolveSubjectGradeNames({ subjectId: input.subject, gradeId: input.grade, }) const subjectName = resolvedNames.subjectName ?? input.subject const gradeName = resolvedNames.gradeName ?? input.grade const buildDescription = (options?: { questionCount?: number }) => buildExamDescription({ subject: subjectName, grade: gradeName, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, scheduledAt: scheduled, questionCount: options?.questionCount, }) return { examId, scheduled, subjectName, gradeName, buildDescription } } const loadAiDraftQuestionsAndStructure = async (input: { rawAiQuestions: string | null rawStructure: string | null title: string subject: string grade: string difficulty: number totalScore: number durationMin: number aiSourceText?: string aiQuestionCount?: number aiProviderId?: string }): Promise< | { ok: true; generated: AiGeneratedQuestion[]; structure: AiGeneratedStructureNode[] } | { ok: false; message: string } > => { if (input.rawAiQuestions) { let parsedQuestions: unknown = null try { parsedQuestions = JSON.parse(input.rawAiQuestions) } catch { return { ok: false, message: "Invalid AI preview payload" } } const validated = z.array(AiInsertQuestionSchema).safeParse(parsedQuestions) if (!validated.success || validated.data.length === 0) { return { ok: false, message: "Invalid AI preview payload" } } const generated: AiGeneratedQuestion[] = validated.data.map((q) => ({ id: q.id, type: q.type, difficulty: q.difficulty, score: q.score, content: { text: q.content.text, ...(q.content.options ? { options: q.content.options.map((opt) => ({ id: opt.id, text: opt.text, isCorrect: opt.isCorrect ?? false, })), } : {}), ...(q.content.subQuestions ? { subQuestions: q.content.subQuestions } : {}), }, })) let structure: AiGeneratedStructureNode[] = [] if (input.rawStructure) { try { const parsedStructure = JSON.parse(input.rawStructure) const validatedStructure = AiGeneratedStructureSchema.safeParse(parsedStructure) if (validatedStructure.success) { structure = validatedStructure.data } else { return { ok: false, message: "Invalid preview structure" } } } catch { return { ok: false, message: "Invalid preview structure" } } } if (structure.length === 0) { structure = generated.map((q) => ({ id: createId(), type: "question", questionId: q.id, score: q.score, })) } return { ok: true, generated, structure } } const sourceText = input.aiSourceText?.trim() if (!sourceText) { return { ok: false, message: "Please analyze and preview before creating" } } const aiDraft = await generateAiCreateDraftFromSource({ title: input.title, subject: input.subject, grade: input.grade, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, questionCount: input.aiQuestionCount, sourceText, aiProviderId: input.aiProviderId, }) if (!aiDraft.ok) { return { ok: false, message: aiDraft.message } } return { ok: true, generated: aiDraft.generated, structure: aiDraft.structure } } const prepareAiPreviewRequest = async (input: { title?: string subject?: string grade?: string difficulty?: number totalScore?: number durationMin?: number aiSourceText: string aiQuestionCount?: number aiProviderId?: string }) => { const resolvedNames = await resolveSubjectGradeNames({ subjectId: input.subject, gradeId: input.grade, }) const title = input.title && input.title.trim().length > 0 ? input.title : "AI Exam" const subjectName = input.subject ? resolvedNames.subjectName ?? input.subject : undefined const gradeName = input.grade ? resolvedNames.gradeName ?? input.grade : undefined return { title, subject: subjectName, grade: gradeName, difficulty: input.difficulty ?? 3, totalScore: input.totalScore ?? 100, durationMin: input.durationMin ?? 90, questionCount: input.aiQuestionCount, sourceText: input.aiSourceText, aiProviderId: input.aiProviderId, } } const parseRegenerateAiQuestionInput = ( formData: FormData ): | { ok: true instruction: string aiProviderId?: string sourceText?: string originalQuestion: z.infer } | { ok: false; state: ActionState } => { const instruction = getStringValue(formData, "instruction")?.trim() const aiProviderId = getStringValue(formData, "aiProviderId")?.trim() const sourceText = getStringValue(formData, "sourceText")?.trim() const questionJson = getStringValue(formData, "questionJson") if (!instruction) { return { ok: false, state: failState("Please enter rewrite instruction") } } if (!questionJson) { return { ok: false, state: failState("No selected question data") } } try { const parsedQuestion = JSON.parse(questionJson) as unknown const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion) if (!validatedQuestion.success) { return { ok: false, state: failState("Selected question format invalid") } } return { ok: true, instruction, aiProviderId, sourceText, originalQuestion: validatedQuestion.data, } } catch { return { ok: false, state: failState("Selected question format invalid") } } } export async function createExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_CREATE) const rawQuestionsValue = formData.get("questionsJson") const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null const parsed = ExamCreateSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), difficulty: getStringValue(formData, "difficulty"), totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null, questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [], }) if (!parsed.success) { return invalidFormState(parsed.error, { useFirstMessage: false }) } const input = parsed.data const context = await prepareExamCreateContext({ subject: input.subject, grade: input.grade, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, scheduledAt: input.scheduledAt, }) const description = context.buildDescription() try { await persistExamDraft({ examId: context.examId, title: input.title, creatorId: ctx.userId, subjectId: input.subject, gradeId: input.grade, scheduledAt: context.scheduled, description, examModeConfig: parseExamModeConfig(formData), }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to create exam") } revalidatePath("/teacher/exams/all") return successState(context.examId, "Exam created successfully.") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const AiExamCreateSchema = ExamCreateSchema.extend({ aiSourceText: z.string().optional(), aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(), aiProviderId: z.string().min(1).optional(), }) const AiExamPreviewSchema = z.object({ title: z.string().optional(), subject: z.string().optional(), grade: z.string().optional(), difficulty: z.coerce.number().int().min(1).max(5).optional(), totalScore: z.coerce.number().int().min(1).optional(), durationMin: z.coerce.number().int().min(1).optional(), aiSourceText: z.string().min(1), aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(), aiProviderId: z.string().min(1).optional(), }) export async function createAiExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE) const rawQuestionsValue = formData.get("questionsJson") const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null const rawAiQuestionsValue = formData.get("aiQuestionsJson") const rawAiQuestions = typeof rawAiQuestionsValue === "string" ? rawAiQuestionsValue : null const rawStructureValue = formData.get("structureJson") const rawStructure = typeof rawStructureValue === "string" ? rawStructureValue : null const aiSourceTextRaw = formData.get("aiSourceText") const aiQuestionCountRaw = formData.get("aiQuestionCount") const aiProviderIdRaw = formData.get("aiProviderId") const parsed = AiExamCreateSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), difficulty: getStringValue(formData, "difficulty"), totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null, questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [], aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined, aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0 ? aiQuestionCountRaw : undefined, aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0 ? aiProviderIdRaw : undefined, }) if (!parsed.success) { return invalidFormState(parsed.error) } const input = parsed.data if (!rawAiQuestions && !input.aiSourceText) { return failState("Please analyze and preview before creating") } const context = await prepareExamCreateContext({ subject: input.subject, grade: input.grade, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, scheduledAt: input.scheduledAt, }) const aiDraftResult = await loadAiDraftQuestionsAndStructure({ rawAiQuestions, rawStructure, title: input.title, subject: context.subjectName, grade: context.gradeName, difficulty: input.difficulty, totalScore: input.totalScore, durationMin: input.durationMin, aiSourceText: input.aiSourceText, aiQuestionCount: input.aiQuestionCount, aiProviderId: input.aiProviderId, }) if (!aiDraftResult.ok) { return failState(aiDraftResult.message) } const { generated, structure } = aiDraftResult const questionCount = generated.length const description = context.buildDescription({ questionCount }) try { await persistAiGeneratedExamDraft({ examId: context.examId, title: input.title, creatorId: ctx.userId, subjectId: input.subject, gradeId: input.grade, scheduledAt: context.scheduled, description, structure, generated, examModeConfig: parseExamModeConfig(formData), }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to create exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控(AI 生成考试) await trackExamEvent("exam.ai_generated", { userId: ctx.userId, targetId: context.examId, properties: { aiSourceText: input.aiSourceText?.length ?? 0, aiQuestionCount: input.aiQuestionCount, }, }) return successState(context.examId, "Exam created successfully.") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function previewAiExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { await requirePermission(Permissions.EXAM_AI_GENERATE) const aiSourceTextRaw = formData.get("aiSourceText") const aiQuestionCountRaw = formData.get("aiQuestionCount") const aiProviderIdRaw = formData.get("aiProviderId") const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : "" if (!sourceText) { return failState("Please paste the full exam text first", { aiSourceText: ["Please paste the full exam text first"], }) } const parsed = AiExamPreviewSchema.safeParse({ title: getStringValue(formData, "title"), subject: getStringValue(formData, "subject"), grade: getStringValue(formData, "grade"), difficulty: getStringValue(formData, "difficulty"), totalScore: getStringValue(formData, "totalScore"), durationMin: getStringValue(formData, "durationMin"), aiSourceText: sourceText, aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0 ? aiQuestionCountRaw : undefined, aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0 ? aiProviderIdRaw : undefined, }) if (!parsed.success) { return invalidFormState(parsed.error) } const input = parsed.data const previewRequest = await prepareAiPreviewRequest(input) const aiDraft = await generateAiPreviewData(previewRequest) if (!aiDraft.ok) { return failState(aiDraft.message) } return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput }) } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function regenerateAiQuestionAction( prevState: ActionState | null, formData: FormData ): Promise> { try { await requirePermission(Permissions.EXAM_AI_GENERATE) const parsedInput = parseRegenerateAiQuestionInput(formData) if (!parsedInput.ok) { return parsedInput.state } const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput const originalDifficulty = originalQuestion.difficulty ?? 3 const originalScore = originalQuestion.score ?? 0 try { const result = await regenerateAiQuestionByInstruction({ instruction, originalQuestion, sourceText, aiProviderId, }) if (!result.ok) { return failState(result.message) } return successState({ type: result.data.type, difficulty: result.data.difficulty ?? originalDifficulty, score: result.data.score ?? originalScore, content: result.data.content, }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("AI question format invalid") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const ExamUpdateSchema = z.object({ examId: z.string().min(1), questions: z .array( z.object({ id: z.string(), score: z.coerce.number().int().min(0), }) ) .optional(), structure: z.unknown().optional(), status: z.enum(["draft", "published", "archived"]).optional(), }) export async function updateExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_UPDATE) const rawQuestions = formData.get("questionsJson") const rawStructure = formData.get("structureJson") const rawQuestionsStr = typeof rawQuestions === "string" ? rawQuestions : null const rawStructureStr = typeof rawStructure === "string" ? rawStructure : null const parsed = ExamUpdateSchema.safeParse({ examId: formData.get("examId"), questions: rawQuestionsStr ? safeJsonParse(rawQuestionsStr, "题目数据格式无效") : undefined, structure: rawStructureStr ? safeJsonParse(rawStructureStr, "试卷结构数据格式无效") : undefined, status: formData.get("status") ?? undefined, }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: "Invalid update data", useFirstMessage: false, }) } const { examId, questions, structure, status } = parsed.data // Ownership check: non-admin users can only update their own exams if (ctx.dataScope.type !== "all") { const creatorId = await getExamCreatorId(examId) if (!creatorId || creatorId !== ctx.userId) { return failState("You can only update exams you created") } } try { await updateExamWithQuestions(examId, { questions: questions ?? undefined, structure, status, }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to update exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.updated", { userId: ctx.userId, targetId: examId, properties: { hasQuestions: !!questions, hasStructure: !!structure, status }, }) return successState(examId, "Exam updated") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const ExamDeleteSchema = z.object({ examId: z.string().min(1), }) export async function deleteExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_DELETE) const parsed = ExamDeleteSchema.safeParse({ examId: formData.get("examId"), }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: "Invalid delete data", useFirstMessage: false, }) } const { examId } = parsed.data // Ownership check: non-admin users can only delete their own exams if (ctx.dataScope.type !== "all") { const creatorId = await getExamCreatorId(examId) if (!creatorId || creatorId !== ctx.userId) { return failState("You can only delete exams you created") } } try { await deleteExamById(examId) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to delete exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.deleted", { userId: ctx.userId, targetId: examId, }) return successState(examId, "Exam deleted") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } const ExamDuplicateSchema = z.object({ examId: z.string().min(1), }) export async function duplicateExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.EXAM_DUPLICATE) const parsed = ExamDuplicateSchema.safeParse({ examId: formData.get("examId"), }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: "Invalid duplicate data", useFirstMessage: false, }) } const { examId } = parsed.data let newExamId: string try { const duplicatedId = await duplicateExam(examId, ctx.userId) if (!duplicatedId) { return failState("Exam not found") } newExamId = duplicatedId } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState("Database error: Failed to duplicate exam") } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.duplicated", { userId: ctx.userId, targetId: newExamId, properties: { sourceExamId: examId }, }) return successState(newExamId, "Exam duplicated") } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function getExamPreviewAction( examId: string ): Promise }>> { try { await requirePermission(Permissions.EXAM_READ) try { const exam = await getExamPreview(examId) if (!exam) { return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found") } return successState({ structure: exam.structure, questions: exam.questions, }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message) } return handleActionError(error) } } export async function getSubjectsAction(): Promise> { try { await requirePermission(Permissions.EXAM_READ) try { const allSubjects = await getExamSubjects() return successState(allSubjects) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState<{ id: string; name: string }[]>("Failed to load subjects") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } return handleActionError(error) } } export async function getGradesAction(): Promise> { try { await requirePermission(Permissions.EXAM_READ) try { const allGrades = await getExamGrades() return successState(allGrades) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState<{ id: string; name: string }[]>("Failed to load grades") } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } return handleActionError(error) } }