Files
NextEdu/src/modules/questions/actions.ts

375 lines
11 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 { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { CreateQuestionSchema } from "./schema"
import type { CreateQuestionInput } from "./schema"
import type { ActionState } from "@/shared/types/action-state"
import { invalidateFor } from "@/shared/lib/cache"
import { z } from "zod"
import {
createQuestionWithRelations,
deleteQuestionByIdRecursive,
deleteQuestionsBatch,
exportQuestions,
getChapterOptions,
getKnowledgePointOptions,
getKnowledgePointOptionsByChapter,
getQuestions,
getTextbookOptions,
importQuestions,
updateQuestionById,
type GetQuestionsParams,
type QuestionExportItem,
type QuestionImportItem,
} from "./data-access"
import type { ChapterOption, KnowledgePointOption, TextbookOption } from "./types"
import { QuestionTypeEnum } from "./schema"
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
import { trackQuestionCreated, trackQuestionUpdated, trackQuestionDeleted } from "./utils/track-event"
/** Result type of getQuestions (data + meta) */
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
/** Result type of getKnowledgePointOptions */
type KnowledgePointOptionsResult = KnowledgePointOption[]
export async function createQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData | CreateQuestionInput,
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_CREATE)
let rawInput: unknown = formData
if (formData instanceof FormData) {
const jsonString = formData.get("json")
if (typeof jsonString === "string") {
rawInput = safeJsonParse<unknown>(jsonString, "题目内容格式无效")
} else {
return { success: false, message: "Invalid submission format. Expected JSON." }
}
}
const validatedFields = CreateQuestionSchema.safeParse(rawInput)
if (!validatedFields.success) {
return {
success: false,
message: "Validation failed",
errors: validatedFields.error.flatten().fieldErrors,
}
}
const input = validatedFields.data
const questionId = await createQuestionWithRelations(input, ctx.userId)
trackQuestionCreated(questionId, input.type, input.difficulty)
await invalidateFor("questions.create")
return {
success: true,
message: "Question created successfully",
data: questionId,
}
} catch (e) {
return handleActionError(e)
}
}
const UpdateQuestionSchema = z.object({
id: z.string().min(1),
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
difficulty: z.number().min(1).max(5),
content: z.record(z.string(), z.unknown()),
knowledgePointIds: z.array(z.string()).optional(),
})
export async function updateQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData,
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_UPDATE)
const canEditAll = ctx.dataScope.type === "all"
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "Invalid submission format. Expected JSON." }
}
const parsed = UpdateQuestionSchema.safeParse(safeJsonParse<unknown>(jsonString, "题目内容格式无效"))
if (!parsed.success) {
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
}
}
const { id, ...updateData } = parsed.data
await updateQuestionById(id, updateData, canEditAll, ctx.userId)
trackQuestionUpdated(id, updateData.type, updateData.difficulty)
await invalidateFor("questions.update")
return { success: true, message: "Question updated successfully", data: id }
} catch (e) {
return handleActionError(e)
}
}
export async function deleteQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData,
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_DELETE)
const canDeleteAll = ctx.dataScope.type === "all"
const questionId = formData.get("questionId")
if (typeof questionId !== "string") {
return { success: false, message: "Invalid question ID" }
}
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId)
trackQuestionDeleted(questionId)
await invalidateFor("questions.delete")
return { success: true, message: "Question deleted successfully", data: questionId }
} catch (e) {
return handleActionError(e)
}
}
const BatchDeleteSchema = z.object({
ids: z.array(z.string().min(1)).min(1, "At least one question ID is required"),
})
/**
* 批量删除题目。
*
* 输入 JSON{ ids: string[] }
* 权限QUESTION_DELETE遵循数据范围过滤。
*/
export async function deleteQuestionsBatchAction(
prevState: ActionState<{ deleted: number }> | undefined,
formData: FormData,
): Promise<ActionState<{ deleted: number }>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_DELETE)
const canDeleteAll = ctx.dataScope.type === "all"
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "Invalid submission format. Expected JSON." }
}
const parsed = BatchDeleteSchema.safeParse(safeJsonParse<unknown>(jsonString, "批量删除参数无效"))
if (!parsed.success) {
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
}
}
const deletedCount = await deleteQuestionsBatch(parsed.data.ids, canDeleteAll, ctx.userId)
for (const id of parsed.data.ids) {
trackQuestionDeleted(id)
}
await invalidateFor("questions.deleteBatch")
return { success: true, message: "Batch delete completed", data: { deleted: deletedCount } }
} catch (e) {
return handleActionError(e)
}
}
export async function getQuestionsAction(
params: GetQuestionsParams,
): Promise<ActionState<QuestionsListResult>> {
try {
await requirePermission(Permissions.QUESTION_READ)
const data = await getQuestions(params)
return { success: true, data }
} catch (e) {
return handleActionError(e)
}
}
export async function getKnowledgePointOptionsAction(): Promise<
ActionState<KnowledgePointOptionsResult>
> {
try {
await requirePermission(Permissions.QUESTION_READ)
const data = await getKnowledgePointOptions()
return { success: true, data }
} catch (e) {
return handleActionError(e)
}
}
/** Result type of getTextbookOptions */
type TextbookOptionsResult = TextbookOption[]
/** Result type of getChapterOptions */
type ChapterOptionsResult = ChapterOption[]
/** Result type of getKnowledgePointOptionsByChapter */
type KnowledgePointOptionsByChapterResult = { id: string; name: string }[]
/**
* 获取教材选项列表(级联筛选第一级)。
*/
export async function getTextbookOptionsAction(): Promise<
ActionState<TextbookOptionsResult>
> {
try {
await requirePermission(Permissions.QUESTION_READ)
const data = await getTextbookOptions()
return { success: true, data }
} catch (e) {
return handleActionError(e)
}
}
/**
* 获取指定教材下的章节选项列表(级联筛选第二级)。
*/
export async function getChapterOptionsAction(
textbookId: string,
): Promise<ActionState<ChapterOptionsResult>> {
try {
await requirePermission(Permissions.QUESTION_READ)
const data = await getChapterOptions(textbookId)
return { success: true, data }
} catch (e) {
return handleActionError(e)
}
}
/**
* 获取指定章节下的知识点选项列表(级联筛选第三级)。
*/
export async function getKnowledgePointOptionsByChapterAction(
chapterId: string,
): Promise<ActionState<KnowledgePointOptionsByChapterResult>> {
try {
await requirePermission(Permissions.QUESTION_READ)
const data = await getKnowledgePointOptionsByChapter(chapterId)
return { success: true, data }
} catch (e) {
return handleActionError(e)
}
}
// ---------------------------------------------------------------------------
// 导入/导出 Server Actions
// ---------------------------------------------------------------------------
const ExportSchema = z.object({
ids: z.array(z.string().min(1)).optional(),
})
/**
* 导出题目为 JSON 格式。
*
* 输入 JSON{ ids?: string[] }(不传 ids 则导出全部,受权限范围限制)
* 权限QUESTION_READ遵循数据范围过滤。
*/
export async function exportQuestionsAction(
formData: FormData,
): Promise<ActionState<QuestionExportItem[]>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_READ)
const canExportAll = ctx.dataScope.type === "all"
const jsonString = formData.get("json")
const inputJson = typeof jsonString === "string" ? jsonString : "{}"
const parsed = ExportSchema.safeParse(safeJsonParse<unknown>(inputJson, "导出参数无效"))
if (!parsed.success) {
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
}
}
const data = await exportQuestions(parsed.data.ids, canExportAll, ctx.userId)
return { success: true, data }
} catch (e) {
return handleActionError(e)
}
}
const ImportItemSchema = z.object({
type: QuestionTypeEnum,
difficulty: z.number().min(1).max(5),
content: z.unknown(),
knowledgePointIds: z.array(z.string()).optional(),
})
const ImportSchema = z.object({
items: z.array(ImportItemSchema).min(1, "至少需要一条题目"),
})
/**
* 批量导入题目。
*
* 输入 JSON{ items: QuestionImportItem[] }
* 权限QUESTION_CREATE。
*/
export async function importQuestionsAction(
prevState: ActionState<{ imported: number }> | undefined,
formData: FormData,
): Promise<ActionState<{ imported: number }>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_CREATE)
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "Invalid submission format. Expected JSON." }
}
const parsed = ImportSchema.safeParse(safeJsonParse<unknown>(jsonString, "导入参数无效"))
if (!parsed.success) {
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
}
}
const items: QuestionImportItem[] = parsed.data.items.map((item) => ({
type: item.type,
difficulty: item.difficulty,
content: item.content,
knowledgePointIds: item.knowledgePointIds,
}))
const createdIds = await importQuestions(items, ctx.userId)
for (let i = 0; i < createdIds.length; i++) {
const item = items[i]
trackQuestionCreated(createdIds[i], item.type, item.difficulty)
}
await invalidateFor("questions.import")
return { success: true, message: "Import completed", data: { imported: createdIds.length } }
} catch (e) {
return handleActionError(e)
}
}