Files
NextEdu/src/modules/exams/actions.ts
SpecialX 978d9a8309
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
2026-06-22 01:06:16 +08:00

785 lines
24 KiB
TypeScript

"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 {
buildExamDescription,
deleteExamById,
duplicateExam,
getExamCreatorId,
getExamGrades,
getExamPreview,
getExamSubjects,
persistAiGeneratedExamDraft,
persistExamDraft,
resolveSubjectGradeNames,
updateExamWithQuestions,
} 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 failState = <T>(message: string, errors?: Record<string, string[]>): ActionState<T> => ({
success: false,
message,
errors,
})
const successState = <T>(data: T, message?: string): ActionState<T> => ({
success: true,
message,
data,
})
const invalidFormState = <T>(
error: z.ZodError,
options?: { fallbackMessage?: string; useFirstMessage?: boolean }
): ActionState<T> => {
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<T>(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<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>("Please enter rewrite instruction") }
}
if (!questionJson) {
return { ok: false, state: failState<AiRewriteQuestionData>("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<AiRewriteQuestionData>("Selected question format invalid") }
}
return {
ok: true,
instruction,
aiProviderId,
sourceText,
originalQuestion: validatedQuestion.data,
}
} catch {
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
}
}
export async function createExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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 ? JSON.parse(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,
})
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("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<string>(error.message)
}
throw 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 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 ? JSON.parse(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>("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<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,
})
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("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<string>(error.message)
}
throw error
}
}
export async function previewAiExamAction(
prevState: ActionState<AiPreviewData> | null,
formData: FormData
): Promise<ActionState<AiPreviewData>> {
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<AiPreviewData>("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<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)
}
throw error
}
}
export async function regenerateAiQuestionAction(
prevState: ActionState<AiRewriteQuestionData> | null,
formData: FormData
): Promise<ActionState<AiRewriteQuestionData>> {
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<AiRewriteQuestionData>(result.message)
}
return successState({
type: result.data.type,
difficulty: result.data.difficulty ?? originalDifficulty,
score: result.data.score ?? originalScore,
content: result.data.content,
})
} catch {
return failState<AiRewriteQuestionData>("AI question format invalid")
}
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AiRewriteQuestionData>(error.message)
}
throw 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 ctx = await requirePermission(Permissions.EXAM_UPDATE)
const rawQuestions = formData.get("questionsJson")
const rawStructure = formData.get("structureJson")
const hasQuestions = typeof rawQuestions === "string"
const hasStructure = typeof rawStructure === "string"
const parsed = ExamUpdateSchema.safeParse({
examId: formData.get("examId"),
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
status: formData.get("status") ?? undefined,
})
if (!parsed.success) {
return invalidFormState<string>(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<string>("You can only update exams you created")
}
}
try {
await updateExamWithQuestions(examId, {
questions: questions ?? undefined,
structure,
status,
})
} catch {
return failState<string>("Database error: Failed to update exam")
}
revalidatePath("/teacher/exams/all")
return successState(examId, "Exam updated")
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw 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 ctx = await requirePermission(Permissions.EXAM_DELETE)
const parsed = ExamDeleteSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return invalidFormState<string>(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<string>("You can only delete exams you created")
}
}
try {
await deleteExamById(examId)
} catch {
return failState<string>("Database error: Failed to delete exam")
}
revalidatePath("/teacher/exams/all")
return successState(examId, "Exam deleted")
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw 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 ctx = await requirePermission(Permissions.EXAM_DUPLICATE)
const parsed = ExamDuplicateSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return invalidFormState<string>(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<string>("Exam not found")
}
newExamId = duplicatedId
} catch {
return failState<string>("Database error: Failed to duplicate exam")
}
revalidatePath("/teacher/exams/all")
return successState(newExamId, "Exam duplicated")
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
throw error
}
}
export async function getExamPreviewAction(
examId: string
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
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(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)
}
throw error
}
}
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
await requirePermission(Permissions.EXAM_READ)
try {
const allSubjects = await getExamSubjects()
return successState(allSubjects)
} catch (error) {
console.error("Failed to fetch subjects:", 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)
}
throw error
}
}
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
try {
await requirePermission(Permissions.EXAM_READ)
try {
const allGrades = await getExamGrades()
return successState(allGrades)
} catch (error) {
console.error("Failed to fetch grades:", 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)
}
throw error
}
}