import "server-only"; import { eq } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; import { db } from "@/shared/db"; import { lessonPlans } from "@/shared/db/schema"; import { createQuestionWithRelations } from "@/modules/questions/data-access"; import { persistExamDraft, addExamQuestions } from "@/modules/exams/data-access"; import { createHomeworkAssignment } from "@/modules/homework/data-access-write"; import { getStudentIdsByClassIds } from "@/modules/classes/data-access"; import { normalizeDocument } from "./data-access"; import { isExerciseBlockData, isLessonPlanStatus, isValidQuestionType } from "./lib/type-guards"; import type { LessonPlanDocument, LessonPlan, LessonPlanStatus } from "./types"; interface PublishInput { planId: string; blockId: string; userId: string; classIds: string[]; availableAt?: Date; dueAt?: Date; /** 作业标题(由 actions 层 i18n 翻译后传入)*/ homeworkTitle: string; /** 作业描述(由 actions 层 i18n 翻译后传入)*/ homeworkDescription: string; } interface PublishResult { examId: string; assignmentId: string; updatedContent: LessonPlanDocument; } /** * publish-service 错误:使用错误码替代硬编码中文, * 由 actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息。 */ export type PublishErrorCode = | "PLAN_NOT_FOUND" | "NO_PERMISSION" | "NO_EXERCISE_BLOCK" | "NO_QUESTIONS" | "ALREADY_PUBLISHED" | "NO_SUBJECT_OR_GRADE" | "NO_STUDENTS" | "INVALID_QUESTION_TYPE"; export class PublishServiceError extends Error { constructor(public readonly code: PublishErrorCode) { super(code); this.name = "PublishServiceError"; } } export async function publishLessonPlanHomework( input: PublishInput, ): Promise { // 1. 读取课案 const rows = await db .select() .from(lessonPlans) .where(eq(lessonPlans.id, input.planId)) .limit(1); if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND"); const row = rows[0]; // 使用类型守卫收窄(替代 as 断言) const status: LessonPlanStatus = isLessonPlanStatus(row.status) ? row.status : "draft"; const plan: LessonPlan = { id: row.id, title: row.title, textbookId: row.textbookId, chapterId: row.chapterId, coursePlanItemId: row.coursePlanItemId, subjectId: row.subjectId, gradeId: row.gradeId, templateId: row.templateId, templateName: row.templateName, content: normalizeDocument(row.content), status, creatorId: row.creatorId, lastSavedAt: row.lastSavedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; if (plan.creatorId !== input.userId) throw new PublishServiceError("NO_PERMISSION"); // 2. 定位 exercise block(使用类型守卫替代 as 断言) const block = plan.content.nodes.find((b) => b.id === input.blockId); if (!block || block.type !== "exercise") throw new PublishServiceError("NO_EXERCISE_BLOCK"); if (!isExerciseBlockData(block.data)) throw new PublishServiceError("NO_EXERCISE_BLOCK"); if (block.data.items.length === 0) throw new PublishServiceError("NO_QUESTIONS"); if (block.data.publishedAssignmentId) throw new PublishServiceError("ALREADY_PUBLISHED"); // 3. inline 题目入库,替换占位 ID const newContent: LessonPlanDocument = structuredClone(plan.content); const newBlock = newContent.nodes.find((b) => b.id === input.blockId); if (!newBlock || newBlock.type !== "exercise") throw new PublishServiceError("NO_EXERCISE_BLOCK"); if (!isExerciseBlockData(newBlock.data)) throw new PublishServiceError("NO_EXERCISE_BLOCK"); const newData = newBlock.data; if (!plan.subjectId || !plan.gradeId) { throw new PublishServiceError("NO_SUBJECT_OR_GRADE"); } const examId = createId(); const assignmentId = createId(); const createdQuestionIds: string[] = []; // P0-12 修复:将多步写操作包裹在事务中,保证原子性。 // 注意:跨模块 data-access(createQuestionWithRelations/persistExamDraft/addExamQuestions/ // createHomeworkAssignment)使用各自的 db 连接,无法加入此事务。 // 若跨模块调用成功但下方事务失败,已创建的 exam/homework 将成为孤儿数据, // 需通过补偿机制或定期清理任务处理。完整事务支持需跨模块 data-access 接受 tx 参数(中长期改进)。 try { for (let i = 0; i < newData.items.length; i++) { const item = newData.items[i]; if (item.source === "inline" && item.inlineContent) { const qt = item.inlineContent.type; // 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误) if (!isValidQuestionType(qt)) { throw new PublishServiceError("INVALID_QUESTION_TYPE"); } const questionId = await createQuestionWithRelations( { content: item.inlineContent.content, type: qt, difficulty: item.inlineContent.difficulty, knowledgePointIds: item.inlineContent.knowledgePointIds, }, input.userId, ); createdQuestionIds.push(questionId); newData.items[i] = { ...item, questionId, inlineContent: undefined, }; } } // 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入) await persistExamDraft({ examId, title: input.homeworkTitle, creatorId: input.userId, subjectId: plan.subjectId, gradeId: plan.gradeId, scheduledAt: undefined, description: input.homeworkDescription, }); // 插入 examQuestions(通过 exams data-access 跨模块接口) await addExamQuestions( examId, newData.items.map((it, i) => ({ questionId: it.questionId, score: it.score, order: i, })), ); // 5. 下发作业 const targetStudentIds = await getStudentIdsByClassIds(input.classIds); if (targetStudentIds.length === 0) { throw new PublishServiceError("NO_STUDENTS"); } await createHomeworkAssignment({ assignmentId, sourceExamId: examId, title: input.homeworkTitle, description: input.homeworkDescription, structure: null, status: "published", creatorId: input.userId, availableAt: input.availableAt ?? null, dueAt: input.dueAt ?? null, allowLate: false, lateDueAt: null, maxAttempts: 1, publish: true, questions: newData.items.map((it, i) => ({ questionId: it.questionId, score: it.score, order: i, })), targetStudentIds, }); // 6. 回写溯源标记 — 包裹在事务中保证原子性 newData.publishedExamId = examId; newData.publishedAssignmentId = assignmentId; newData.publishedAt = new Date().toISOString(); await db.transaction(async (tx) => { await tx .update(lessonPlans) .set({ content: newContent }) .where(eq(lessonPlans.id, input.planId)); }); } catch (e) { // 补偿错误处理:记录已创建的资源 ID 便于排查孤儿数据 if (e instanceof PublishServiceError) throw e; console.error("[publishLessonPlanHomework] 部分失败,可能存在孤儿数据", { planId: input.planId, examId, assignmentId, createdQuestionIds, error: e instanceof Error ? e.message : String(e), }); throw e; } return { examId, assignmentId, updatedContent: newContent }; }