- 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
223 lines
7.4 KiB
TypeScript
223 lines
7.4 KiB
TypeScript
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-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 };
|
||
}
|