"use server" import { revalidatePath } from "next/cache" import { getTranslations } from "next-intl/server" 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 { handleActionError, safeJsonParse, } from "@/shared/lib/action-utils" import { trackExamEvent } from "@/shared/lib/track-event" import { deleteExamById, duplicateExam, getExamCreatorId, getExamGrades, getExamPreview, getExamSubjects, getExamsByGradeId, persistAiGeneratedExamDraft, persistExamDraft, resolveSubjectGradeNames, updateExamWithQuestions, } from "./data-access" import { AiQuestionSchema, generateAiPreviewData, loadAiDraftQuestionsAndStructure, regenerateAiQuestionByInstruction, } from "./ai-pipeline" import type { AiPreviewData, AiRewriteQuestionData, } from "./ai-pipeline" import type { GradeExamsResult } from "./types" import { failState, getStringValue, invalidFormState, parseExamModeConfig, prepareExamCreateContext, successState, } from "./actions-helpers" // Re-export 从拆分文件迁移的类型导出,保持外部 import 路径不变 // 注意:Next.js 16 禁止在 "use server" 文件中 re-export 值(仅允许 async 函数导出), // autoMarkExamAction / createExamFromRichEditorAction / updateExamFromRichEditorAction // 的值 re-export 已移除,调用方直接从源文件 import。 export type { AutoMarkResult } from "./ai-pipeline/auto-mark" 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 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, t: Awaited> ): | { 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(t("enterRewriteInstruction")) } } if (!questionJson) { return { ok: false, state: failState(t("noSelectedQuestion")) } } try { const parsedQuestion = JSON.parse(questionJson) as unknown const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion) if (!validatedQuestion.success) { return { ok: false, state: failState(t("questionFormatInvalid")) } } return { ok: true, instruction, aiProviderId, sourceText, originalQuestion: validatedQuestion.data, } } catch { return { ok: false, state: failState(t("questionFormatInvalid")) } } } export async function createExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const t = await getTranslations("examHomework.exam.actionMessages") 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(t("dbCreateFailed")) } revalidatePath("/teacher/exams/all") return successState(context.examId, t("createdSuccess")) } 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 t = await getTranslations("examHomework.exam.actionMessages") 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(t("analyzeFirst")) } 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(t("dbCreateFailed")) } 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, t("createdSuccess")) } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function previewAiExamAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const t = await getTranslations("examHomework.exam.actionMessages") 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(t("pasteSourceFirst"), { aiSourceText: [t("pasteSourceFirst")], }) } 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 { const t = await getTranslations("examHomework.exam.actionMessages") await requirePermission(Permissions.EXAM_AI_GENERATE) const parsedInput = parseRegenerateAiQuestionInput(formData, t) 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(t("aiQuestionFormatInvalid")) } } 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 t = await getTranslations("examHomework.exam.actionMessages") 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: t("invalidUpdateData"), 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(t("onlyOwnUpdate")) } } try { await updateExamWithQuestions(examId, { questions: questions ?? undefined, structure, status, }) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState(t("dbUpdateFailed")) } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.updated", { userId: ctx.userId, targetId: examId, properties: { hasQuestions: !!questions, hasStructure: !!structure, status }, }) return successState(examId, t("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 t = await getTranslations("examHomework.exam.actionMessages") const ctx = await requirePermission(Permissions.EXAM_DELETE) const parsed = ExamDeleteSchema.safeParse({ examId: formData.get("examId"), }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: t("invalidDeleteData"), 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(t("onlyOwnDelete")) } } try { await deleteExamById(examId) } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState(t("dbDeleteFailed")) } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.deleted", { userId: ctx.userId, targetId: examId, }) return successState(examId, t("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 t = await getTranslations("examHomework.exam.actionMessages") const ctx = await requirePermission(Permissions.EXAM_DUPLICATE) const parsed = ExamDuplicateSchema.safeParse({ examId: formData.get("examId"), }) if (!parsed.success) { return invalidFormState(parsed.error, { fallbackMessage: t("invalidDuplicateData"), useFirstMessage: false, }) } const { examId } = parsed.data let newExamId: string try { const duplicatedId = await duplicateExam(examId, ctx.userId) if (!duplicatedId) { return failState(t("notFound")) } newExamId = duplicatedId } catch (error) { console.error("[ExamAction]", error instanceof Error ? error.message : String(error)) return failState(t("dbDuplicateFailed")) } revalidatePath("/teacher/exams/all") // V3-4: 埋点监控 await trackExamEvent("exam.duplicated", { userId: ctx.userId, targetId: newExamId, properties: { sourceExamId: examId }, }) return successState(newExamId, t("duplicated")) } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } } export async function getExamPreviewAction( examId: string ): Promise }>> { try { const t = await getTranslations("examHomework.exam.actionMessages") await requirePermission(Permissions.EXAM_READ) try { const exam = await getExamPreview(examId) if (!exam) { return failState<{ structure: unknown; questions: Array<{ id: string }> }>(t("notFound")) } 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 }> }>(t("loadPreviewFailed")) } } 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 { const t = await getTranslations("examHomework.exam.actionMessages") 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 }[]>(t("loadSubjectsFailed")) } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } return handleActionError(error) } } export async function getGradesAction(): Promise> { try { const t = await getTranslations("examHomework.exam.actionMessages") 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 }[]>(t("loadGradesFailed")) } } catch (error) { if (error instanceof PermissionDeniedError) { return failState<{ id: string; name: string }[]>(error.message) } return handleActionError(error) } } /** * 年级仪表盘 - 维度3:获取年级下所有考试 + 提交统计。 */ export async function getExamsByGradeIdAction( gradeId: string ): Promise> { try { const t = await getTranslations("examHomework.exam.actionMessages") const ctx = await requirePermission(Permissions.EXAM_READ) if (!gradeId || gradeId.trim().length === 0) { return failState(t("invalidGradeId")) } const result = await getExamsByGradeId({ gradeId, scope: ctx.dataScope, }) return successState(result) } catch (error) { if (error instanceof PermissionDeniedError) { return failState(error.message) } return handleActionError(error) } }