Files
NextEdu/src/modules/lesson-preparation/publish-service.ts
SpecialX 20691f53ce feat(lesson-preparation): 备课模块审计重构 — 跨模块解耦 + i18n + 纯函数抽取 + 错误边界
P0-1 跨模块直查修复:publish-service 不再直查 examQuestions 表,新增 exams/data-access.addExamQuestions 接口,复用 classes/data-access.getStudentIdsByClassIds

P0-2 i18n 接入:新增 zh-CN/en 翻译文件,注册 lessonPreparation 命名空间,17 个组件改造为 useTranslations/getTranslations

P1 纯函数抽取:lib/document-migration.ts(类型守卫替代 as 断言)、lib/node-summary.ts(翻译函数注入)、lib/rf-mappers.ts

P1 错误边界+骨架屏:新增 LessonPlanErrorBoundary 和 4 个 Skeleton 组件

P1 Block 注册表:新增 config/block-registry.tsx(BlockRenderer 组件),node-edit-panel 重构为配置驱动渲染

P1 其他修复:exercise-block 改用 router.refresh(),node-editor/lesson-node 复用 lib/ 纯函数

架构图同步:更新 004 和 005 文档

Refs: docs/architecture/audit/lesson-preparation-audit-report.md
2026-06-22 16:17:58 +08:00

156 lines
4.6 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 type { LessonPlanDocument, ExerciseBlockData } from "./types";
interface PublishInput {
planId: string;
blockId: string;
userId: string;
classIds: string[];
availableAt?: Date;
dueAt?: Date;
}
interface PublishResult {
examId: string;
assignmentId: string;
updatedContent: LessonPlanDocument;
}
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 Error("课案不存在");
const row = rows[0] as unknown as {
id: string;
content: unknown;
creatorId: string;
title: string;
textbookId: string | null;
chapterId: string | null;
subjectId: string | null;
gradeId: string | null;
};
const plan = {
...row,
content: normalizeDocument(row.content),
};
if (plan.creatorId !== input.userId)
throw new Error("无权发布");
// 2. 定位 exercise block
const block = plan.content.nodes.find((b) => b.id === input.blockId);
if (!block || block.type !== "exercise")
throw new Error("练习块不存在");
const data = block.data as ExerciseBlockData;
if (data.items.length === 0) throw new Error("练习块无题目");
if (data.publishedAssignmentId)
throw new Error("该练习块已发布,请使用'重新发布'");
// 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 Error("练习块不存在");
const newData = newBlock.data as ExerciseBlockData;
for (let i = 0; i < newData.items.length; i++) {
const item = newData.items[i];
if (item.source === "inline" && item.inlineContent) {
const questionId = await createQuestionWithRelations(
{
content: item.inlineContent.content,
type: item.inlineContent.type as never,
difficulty: item.inlineContent.difficulty,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},
input.userId,
);
newData.items[i] = {
...item,
questionId,
inlineContent: undefined,
};
}
}
// 4. 打包 exam 草稿
const examId = createId();
if (!plan.subjectId || !plan.gradeId) {
throw new Error("课案缺少学科或年级信息,无法发布");
}
await persistExamDraft({
examId,
title: `${plan.title} - 作业`,
creatorId: input.userId,
subjectId: plan.subjectId,
gradeId: plan.gradeId,
scheduledAt: undefined,
description: `来自课案:${plan.title}`,
});
// 插入 examQuestions通过 exams data-access 跨模块接口)
await addExamQuestions(
examId,
newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
);
// 5. 下发作业
const assignmentId = createId();
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
if (targetStudentIds.length === 0) {
throw new Error("所选班级无学生");
}
await createHomeworkAssignment({
assignmentId,
sourceExamId: examId,
title: `${plan.title} - 作业`,
description: `来自课案:${plan.title}`,
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
.update(lessonPlans)
.set({ content: newContent })
.where(eq(lessonPlans.id, input.planId));
return { examId, assignmentId, updatedContent: newContent };
}