Files
NextEdu/src/modules/lesson-preparation/publish-service.ts
SpecialX 20023e13fd feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff
- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
2026-07-03 10:25:21 +08:00

223 lines
7.4 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.
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<PublishResult> {
// 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-accesscreateQuestionWithRelations/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 };
}