import "server-only"; import { cache } from "react"; import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; import { db } from "@/shared/db"; import { lessonPlans, lessonPlanTemplates, textbooks, chapters, subjects, grades, users, } from "@/shared/db/schema"; import type { DataScope } from "@/shared/types/permissions"; import { SYSTEM_TEMPLATES } from "./constants"; import { migrateV1ToV2, normalizeDocument, buildInitialContent, } from "./lib/document-migration"; import type { LessonPlan, LessonPlanDocument, LessonPlanListItem, LessonPlanTemplate, LessonPlanStatus, TemplateType, TemplateScope, } from "./types"; // re-export 纯函数保持向后兼容 export { migrateV1ToV2, normalizeDocument, buildInitialContent }; // ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)---- export class LessonPlanDataError extends Error { constructor(public readonly code: "NOT_FOUND" | "TEMPLATE_NOT_FOUND") { super(code); this.name = "LessonPlanDataError"; } } // ---- 类型守卫:安全地将 DB string 收窄为联合类型 ---- const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const; function isLessonPlanStatus(v: string): v is LessonPlanStatus { return (LESSON_PLAN_STATUSES as readonly string[]).includes(v); } const TEMPLATE_TYPES = ["system", "personal"] as const; function isTemplateType(v: string): v is TemplateType { return (TEMPLATE_TYPES as readonly string[]).includes(v); } const TEMPLATE_SCOPES = ["regular", "review", "experiment", "inquiry", "blank", "custom"] as const; function isTemplateScope(v: string): v is TemplateScope { return (TEMPLATE_SCOPES as readonly string[]).includes(v); } // ---- 类型映射:Drizzle 行 → LessonPlan(Date → ISO string)---- function mapRowToLessonPlan(row: { id: string; title: string; textbookId: string | null; chapterId: string | null; coursePlanItemId: string | null; subjectId: string | null; gradeId: string | null; templateId: string | null; templateName: string | null; content: unknown; status: string; creatorId: string; lastSavedAt: Date | null; createdAt: Date; updatedAt: Date; }): LessonPlan { return { 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: isLessonPlanStatus(row.status) ? row.status : "draft", creatorId: row.creatorId, lastSavedAt: row.lastSavedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } // ---- 类型映射:Drizzle 行 → LessonPlanListItem ---- function mapRowToListItem(row: { id: string; title: string; textbookId: string | null; chapterId: string | null; coursePlanItemId: string | null; subjectId: string | null; gradeId: string | null; templateId: string | null; templateName: string | null; content: unknown; status: string; creatorId: string; lastSavedAt: Date | null; createdAt: Date; updatedAt: Date; textbookTitle: string | null; chapterTitle: string | null; subjectName: string | null; gradeName: string | null; creatorName: string | null; }): LessonPlanListItem { return { ...mapRowToLessonPlan(row), textbookTitle: row.textbookTitle, chapterTitle: row.chapterTitle, subjectName: row.subjectName, gradeName: row.gradeName, creatorName: row.creatorName, }; } // ---- 类型映射:Drizzle 行 → LessonPlanTemplate ---- function mapRowToTemplate(row: { id: string; name: string; type: string; scope: string; blocks: unknown; creatorId: string | null; createdAt: Date; updatedAt: Date; }): LessonPlanTemplate { return { id: row.id, name: row.name, type: isTemplateType(row.type) ? row.type : "personal", scope: isTemplateScope(row.scope) ? row.scope : "custom", // 从 unknown 转换为 TemplateBlockSkeleton[](DB JSON 字段) blocks: row.blocks as LessonPlanTemplate["blocks"], creatorId: row.creatorId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } // ---- DataScope → 查询条件 ---- // P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案 function buildScopeCondition(scope: DataScope, userId: string): SQL[] { switch (scope.type) { case "all": return []; case "owned": return [eq(lessonPlans.creatorId, userId)]; case "class_taught": { // 教师:自己创建的 + published 且属于自己教授学科的 const own = eq(lessonPlans.creatorId, userId); const publishedFilter = sql`(${lessonPlans.status} = 'published')`; const subjectFilter = scope.subjectIds && scope.subjectIds.length > 0 ? inArray(lessonPlans.subjectId, scope.subjectIds) : sql`true`; return [ or( own, and(publishedFilter, subjectFilter), )!, ]; } case "grade_managed": { // 教研组长/年级主任:自己创建的 + published 且属于自己管理的年级 const own = eq(lessonPlans.creatorId, userId); const publishedFilter = sql`(${lessonPlans.status} = 'published')`; const gradeFilter = scope.gradeIds.length > 0 ? inArray(lessonPlans.gradeId, scope.gradeIds) : sql`false`; return [ or( own, and(publishedFilter, gradeFilter), )!, ]; } case "class_members": { // 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧) const publishedFilter = sql`(${lessonPlans.status} = 'published')`; return [publishedFilter]; } case "children": { // 家长:仅查看 published 课案(同学生) const publishedFilter = sql`(${lessonPlans.status} = 'published')`; return [publishedFilter]; } } } // ---- 课案列表 ---- export const getLessonPlans = cache( async ( params: { query?: string; textbookId?: string; chapterId?: string; subjectId?: string; status?: string; }, scope: DataScope, userId: string, ): Promise => { const conditions: SQL[] = [ sql`${lessonPlans.status} != 'archived'`, ]; conditions.push(...buildScopeCondition(scope, userId)); if (params.query) { conditions.push(like(lessonPlans.title, `%${params.query}%`)); } if (params.textbookId) conditions.push(eq(lessonPlans.textbookId, params.textbookId)); if (params.chapterId) conditions.push(eq(lessonPlans.chapterId, params.chapterId)); if (params.subjectId) conditions.push(eq(lessonPlans.subjectId, params.subjectId)); if (params.status) conditions.push(eq(lessonPlans.status, params.status)); const rows = await db .select({ id: lessonPlans.id, title: lessonPlans.title, textbookId: lessonPlans.textbookId, chapterId: lessonPlans.chapterId, coursePlanItemId: lessonPlans.coursePlanItemId, subjectId: lessonPlans.subjectId, gradeId: lessonPlans.gradeId, templateId: lessonPlans.templateId, templateName: lessonPlans.templateName, content: lessonPlans.content, status: lessonPlans.status, creatorId: lessonPlans.creatorId, lastSavedAt: lessonPlans.lastSavedAt, createdAt: lessonPlans.createdAt, updatedAt: lessonPlans.updatedAt, textbookTitle: textbooks.title, chapterTitle: chapters.title, subjectName: subjects.name, gradeName: grades.name, creatorName: users.name, }) .from(lessonPlans) .leftJoin(textbooks, eq(lessonPlans.textbookId, textbooks.id)) .leftJoin(chapters, eq(lessonPlans.chapterId, chapters.id)) .leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id)) .leftJoin(grades, eq(lessonPlans.gradeId, grades.id)) .leftJoin(users, eq(lessonPlans.creatorId, users.id)) .where(and(...conditions)) .orderBy(desc(lessonPlans.updatedAt)); const items = rows.map(mapRowToListItem); return items; }, ); // ---- 单课案 ---- export const getLessonPlanById = cache( async (id: string, userId: string): Promise => { const rows = await db .select() .from(lessonPlans) .where(eq(lessonPlans.id, id)) .limit(1); if (rows.length === 0) return null; const row = rows[0]; // 权限:creator 可看 draft;非 creator 仅 published if (row.creatorId !== userId && row.status !== "published") return null; return mapRowToLessonPlan(row); }, ); // ---- 创建 ---- // V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本 export async function createLessonPlan(input: { title: string; textbookId?: string; chapterId?: string; subjectId?: string; gradeId?: string; templateId: string; creatorId: string; translateTitle?: (key: string) => string; }): Promise<{ planId: string }> { const template = await getTemplateById(input.templateId); if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND"); const planId = createId(); // 如果提供了翻译函数,将模板中的 i18n 键翻译为实际文本 const blocks = input.translateTitle ? template.blocks.map((b) => ({ ...b, title: input.translateTitle!(b.title), })) : template.blocks; const content = buildInitialContent(blocks); await db.insert(lessonPlans).values({ id: planId, title: input.title, textbookId: input.textbookId ?? null, chapterId: input.chapterId ?? null, subjectId: input.subjectId ?? null, gradeId: input.gradeId ?? null, templateId: template.id, templateName: template.name, content, status: "draft", creatorId: input.creatorId, lastSavedAt: new Date(), }); return { planId }; } // ---- 更新 content(自动保存,不生成版本)---- export async function updateLessonPlanContent( planId: string, userId: string, patch: { title?: string; content: LessonPlanDocument }, ): Promise { await db .update(lessonPlans) .set({ ...(patch.title ? { title: patch.title } : {}), content: patch.content, lastSavedAt: new Date(), }) .where( and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)), ); } // ---- 软删除 ---- export async function softDeleteLessonPlan( planId: string, userId: string, ): Promise { await db .update(lessonPlans) .set({ status: "archived" }) .where( and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)), ); } // ---- 复制 ---- export async function duplicateLessonPlan( planId: string, userId: string, ): Promise<{ newPlanId: string }> { const src = await getLessonPlanById(planId, userId); if (!src) throw new LessonPlanDataError("NOT_FOUND"); const newId = createId(); await db.insert(lessonPlans).values({ id: newId, title: `${src.title} - 副本`, textbookId: src.textbookId, chapterId: src.chapterId, subjectId: src.subjectId, gradeId: src.gradeId, templateId: src.templateId, templateName: src.templateName, content: src.content, status: "draft", creatorId: userId, lastSavedAt: new Date(), }); return { newPlanId: newId }; } // ---- 模板查询(内部)---- export async function getTemplateById( templateId: string, ): Promise { // 先查 system 固定模板 const sysDef = SYSTEM_TEMPLATES.find((t) => t.id === templateId); if (sysDef) { return { id: sysDef.id, name: sysDef.name, type: "system", scope: sysDef.scope, blocks: sysDef.blocks, creatorId: null, createdAt: "", updatedAt: "", }; } // 再查 DB(personal 模板) const rows = await db .select() .from(lessonPlanTemplates) .where(eq(lessonPlanTemplates.id, templateId)) .limit(1); return rows.length > 0 ? mapRowToTemplate(rows[0]) : null; }