Files
NextEdu/src/modules/exams/actions.ts
SpecialX 98429e87eb feat(business): update audit, auth, course-plans, announcements, exams
- audit: update retention.ts, types.ts, audit-retention-settings.tsx

- auth: update actions.ts and types.ts

- course-plans: update actions.ts, course-plan-detail.tsx, template-picker-dialog.tsx,

  add lib/track-event.ts

- announcements: update page.tsx, announcement-list.tsx, announcement-pagination.tsx

- exams: update actions.ts
2026-07-04 10:23:03 +08:00

724 lines
23 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 { 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<ReturnType<typeof getTranslations>>
):
| {
ok: true
instruction: string
aiProviderId?: string
sourceText?: string
originalQuestion: z.infer<typeof AiQuestionSchema>
}
| { ok: false; state: ActionState<AiRewriteQuestionData> } => {
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<AiRewriteQuestionData>(t("enterRewriteInstruction")) }
}
if (!questionJson) {
return { ok: false, state: failState<AiRewriteQuestionData>(t("noSelectedQuestion")) }
}
try {
const parsedQuestion = JSON.parse(questionJson) as unknown
const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion)
if (!validatedQuestion.success) {
return { ok: false, state: failState<AiRewriteQuestionData>(t("questionFormatInvalid")) }
}
return {
ok: true,
instruction,
aiProviderId,
sourceText,
originalQuestion: validatedQuestion.data,
}
} catch {
return { ok: false, state: failState<AiRewriteQuestionData>(t("questionFormatInvalid")) }
}
}
export async function createExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string>(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<string>(t("dbCreateFailed"))
}
revalidatePath("/teacher/exams/all")
return successState(context.examId, t("createdSuccess"))
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string>(parsed.error)
}
const input = parsed.data
if (!rawAiQuestions && !input.aiSourceText) {
return failState<string>(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<string>(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<string>(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<string>(error.message)
}
return handleActionError(error)
}
}
export async function previewAiExamAction(
prevState: ActionState<AiPreviewData> | null,
formData: FormData
): Promise<ActionState<AiPreviewData>> {
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<AiPreviewData>(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<AiPreviewData>(parsed.error)
}
const input = parsed.data
const previewRequest = await prepareAiPreviewRequest(input)
const aiDraft = await generateAiPreviewData(previewRequest)
if (!aiDraft.ok) {
return failState<AiPreviewData>(aiDraft.message)
}
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AiPreviewData>(error.message)
}
return handleActionError(error)
}
}
export async function regenerateAiQuestionAction(
prevState: ActionState<AiRewriteQuestionData> | null,
formData: FormData
): Promise<ActionState<AiRewriteQuestionData>> {
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<AiRewriteQuestionData>(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<AiRewriteQuestionData>(t("aiQuestionFormatInvalid"))
}
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AiRewriteQuestionData>(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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string>(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<string>(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<string>(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<string>(error.message)
}
return handleActionError(error)
}
}
const ExamDeleteSchema = z.object({
examId: z.string().min(1),
})
export async function deleteExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string>(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<string>(t("onlyOwnDelete"))
}
}
try {
await deleteExamById(examId)
} catch (error) {
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
return failState<string>(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<string>(error.message)
}
return handleActionError(error)
}
}
const ExamDuplicateSchema = z.object({
examId: z.string().min(1),
})
export async function duplicateExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<string>(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<string>(t("notFound"))
}
newExamId = duplicatedId
} catch (error) {
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
return failState<string>(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<string>(error.message)
}
return handleActionError(error)
}
}
export async function getExamPreviewAction(
examId: string
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
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<ActionState<{ id: string; name: string }[]>> {
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<ActionState<{ id: string; name: string }[]>> {
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<ActionState<GradeExamsResult>> {
try {
const t = await getTranslations("examHomework.exam.actionMessages")
const ctx = await requirePermission(Permissions.EXAM_READ)
if (!gradeId || gradeId.trim().length === 0) {
return failState<GradeExamsResult>(t("invalidGradeId"))
}
const result = await getExamsByGradeId({
gradeId,
scope: ctx.dataScope,
})
return successState(result)
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<GradeExamsResult>(error.message)
}
return handleActionError(error)
}
}