375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
"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)
|
||
}
|
||
}
|