# 数据模型重构设计 > 创建时间:2026-02-27 > 基于需求:17-课程包套餐重构需求.md > 状态:设计中 --- ## 一、设计原则 ### 1.1 重构原则 | 原则 | 说明 | |-----|------| | **向后兼容** | 新结构与旧结构可以共存,支持渐进式迁移 | | **数据安全** | 迁移过程中不丢失任何现有数据 | | **最小变更** | 保留可复用的表和字段,减少不必要的重构 | | **可扩展性** | 新结构支持后续功能迭代 | ### 1.2 参考最佳实践 基于以下资源的最佳实践: - [Prisma Schema Design Best Practices](https://blog.csdn.net/gitblog_00740/article/details/151825815) - LMS系统数据模型设计 - [Microsoft Azure - SaaS Tenancy Patterns](https://learn.microsoft.com/zh-cn/azure/azure-sql/database/saas-tenancy-app-design-patterns) - 多租户SaaS架构模式 - [Prisma Migration Strategy](https://www.prisma.io/docs/concepts/components/prisma-migrate) - 生产环境迁移策略 --- ## 二、表结构变更概览 ### 2.1 变更清单 | 变更类型 | 表名 | 说明 | |---------|------|------| | **🆕 新增** | CoursePackage | 课程套餐 | | **🆕 新增** | CoursePackageCourse | 套餐-课程包关联 | | **🆕 新增** | TenantPackage | 租户-套餐授权 | | **🆕 新增** | Theme | 主题字典 | | **🆕 新增** | CourseLesson | 课程(导入课/集体课/领域课) | | **🆕 新增** | LessonStep | 教学环节 | | **🆕 新增** | LessonStepResource | 环节资源关联 | | **🆕 新增** | SchoolCourse | 校本课程包 | | **🔄 重构** | Course | 课程包(字段大幅调整) | | **🔄 调整** | Tenant | 新增套餐相关字段 | | **❌ 废弃** | CourseScript | 被 CourseLesson + LessonStep 替代 | | **❌ 废弃** | CourseScriptPage | 被 LessonStep 替代 | | **❌ 废弃** | CourseActivity | 并入 CourseLesson | | **⚠️ 保留** | TenantCourse | 保留,作为套餐授权的补充 | ### 2.2 表关系图 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 核心关系图 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ │ │ │ CoursePackage│ ◄─────────────────────────────────────────────┐ │ │ │ (课程套餐) │ │ │ │ └───────┬──────┘ │ │ │ │ 1:N │ │ │ ▼ │ │ │ ┌──────────────────┐ N:1 ┌────────────┐ │ │ │ │CoursePackageCourse├─────────────►│ Course │ │ │ │ │ (套餐-课程关联) │ │ (课程包) │ │ │ │ └──────────────────┘ └──────┬─────┘ │ │ │ │ 1:N │ │ │ ▼ │ │ │ ┌──────────────┐ ┌────────────┐ │ │ │ │TenantPackage │ │CourseLesson│ │ │ │ │(租户-套餐授权)├─────────────────►│ (课程) │ │ │ │ └──────────────┘ └──────┬─────┘ │ │ │ │ │ 1:N │ │ │ │ N:1 ▼ │ │ │ ▼ ┌────────────┐ │ │ │ ┌──────────────┐ │ LessonStep │ │ │ │ │ Tenant │ │ (教学环节) │ │ │ │ │ (租户) │ └──────┬─────┘ │ │ │ └──────────────┘ │ N:N │ │ │ ▼ │ │ │ ┌────────────────┐ │ │ │ │LessonStepResource│ │ │ │ │ (环节资源关联) │ │ │ │ └────────────────┘ │ │ │ │ │ │ ┌──────────────┐ │ │ │ │SchoolCourse │────────────────────────────────────────┘ │ │ │ (校本课程包) │ 基于Course创建 │ │ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- ## 三、新增表设计 ### 3.1 CoursePackage(课程套餐) ```prisma // ==================== 课程套餐 ==================== model CoursePackage { id Int @id @default(autoincrement()) // 基本信息 name String description String? @db.Text // 定价 price Int @default(0) // 原价(分) discountPrice Int? @map("discount_price") // 折扣价(分) discountType String? @map("discount_type") // 折扣类型:EARLY_BIRD, RENEWAL, GROUP // 适用年级(多选) gradeLevels String @default("[]") @map("grade_levels") // JSON: ["XIAO_BAN", "ZHONG_BAN"] // 状态 status String @default("DRAFT") // DRAFT, PENDING, PUBLISHED, ARCHIVED // 审核相关 submittedAt DateTime? @map("submitted_at") submittedBy Int? @map("submitted_by") reviewedAt DateTime? @map("reviewed_at") reviewedBy Int? @map("reviewed_by") reviewComment String? @map("review_comment") @db.Text // 统计 courseCount Int @default(0) @map("course_count") tenantCount Int @default(0) @map("tenant_count") // 创建者 createdBy Int? @map("created_by") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") publishedAt DateTime? @map("published_at") // 关联 courses CoursePackageCourse[] tenantPackages TenantPackage[] @@index([status]) @@map("course_packages") } ``` ### 3.2 CoursePackageCourse(套餐-课程包关联) ```prisma // ==================== 套餐-课程包关联 ==================== model CoursePackageCourse { id Int @id @default(autoincrement()) packageId Int @map("package_id") courseId Int @map("course_id") // 年级分组 gradeLevel String @map("grade_level") // XIAO_BAN, ZHONG_BAN, DA_BAN // 排序 sortOrder Int @default(0) @map("sort_order") createdAt DateTime @default(now()) @map("created_at") // 关联 package CoursePackage @relation(fields: [packageId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) @@unique([packageId, courseId]) @@index([packageId, gradeLevel]) @@map("course_package_courses") } ``` ### 3.3 TenantPackage(租户-套餐授权) ```prisma // ==================== 租户-套餐授权 ==================== model TenantPackage { id Int @id @default(autoincrement()) tenantId Int @map("tenant_id") packageId Int @map("package_id") // 授权时间 startDate DateTime @map("start_date") endDate DateTime @map("end_date") // 授权状态 status String @default("ACTIVE") // ACTIVE, EXPIRED, CANCELLED // 价格记录(授权时的价格) pricePaid Int @default(0) @map("price_paid") // 实际支付价格(分) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 关联 tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) package CoursePackage @relation(fields: [packageId], references: [id], onDelete: Cascade) @@unique([tenantId, packageId]) @@index([tenantId, status]) @@index([endDate]) @@map("tenant_packages") } ``` ### 3.4 Theme(主题字典) ```prisma // ==================== 主题字典 ==================== model Theme { id Int @id @default(autoincrement()) name String @unique description String? @db.Text // 排序权重 sortOrder Int @default(0) @map("sort_order") // 状态 status String @default("ACTIVE") // ACTIVE, ARCHIVED createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 关联 courses Course[] @@index([sortOrder]) @@map("themes") } ``` ### 3.5 CourseLesson(课程) ```prisma // ==================== 课程(导入课/集体课/领域课) ==================== model CourseLesson { id Int @id @default(autoincrement()) courseId Int @map("course_id") // 课程类型 lessonType String @map("lesson_type") // INTRODUCTION(导入课), COLLECTIVE(集体课), // LANGUAGE(语言), HEALTH(健康), SCIENCE(科学), // SOCIAL(社会), ART(艺术) // 基本信息 name String description String? @db.Text // 时长(分钟) duration Int @default(25) // 核心资源(仅集体课和领域课) videoPath String? @map("video_path") // 动画视频 videoName String? @map("video_name") pptPath String? @map("ppt_path") // 课件PPT pptName String? @map("ppt_name") pdfPath String? @map("pdf_path") // 电子绘本PDF pdfName String? @map("pdf_name") // 教学目标(富文本) objectives String? @db.Text // 教学准备(富文本) preparation String? @db.Text // 教学延伸(富文本,集体课和领域课有) extension String? @db.Text // 教学反思(富文本) reflection String? @db.Text // 测评工具 assessmentData String? @map("assessment_data") @db.Text // JSON存储 // 是否使用4环节模板(仅集体课) useTemplate Boolean @default(false) @map("use_template") // 排序 sortOrder Int @default(0) @map("sort_order") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 关联 course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) steps LessonStep[] @@unique([courseId, lessonType]) @@index([courseId]) @@map("course_lessons") } ``` ### 3.6 LessonStep(教学环节) ```prisma // ==================== 教学环节 ==================== model LessonStep { id Int @id @default(autoincrement()) lessonId Int @map("lesson_id") // 环节基本信息 name String content String @db.Text // 环节内容(富文本) // 时长(分钟) duration Int @default(5) // 教学目的(富文本) objective String? @db.Text // 关联资源ID列表 resourceIds String? @map("resource_ids") // JSON: [1, 2, 3] // 排序 sortOrder Int @default(0) @map("sort_order") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 关联 lesson CourseLesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) stepResources LessonStepResource[] @@index([lessonId]) @@map("lesson_steps") } ``` ### 3.7 LessonStepResource(环节资源关联) ```prisma // ==================== 环节资源关联 ==================== model LessonStepResource { id Int @id @default(autoincrement()) stepId Int @map("step_id") resourceId Int @map("resource_id") sortOrder Int @default(0) @map("sort_order") createdAt DateTime @default(now()) @map("created_at") // 关联 step LessonStep @relation(fields: [stepId], references: [id], onDelete: Cascade) resource CourseResource @relation(fields: [resourceId], references: [id], onDelete: Cascade) @@unique([stepId, resourceId]) @@map("lesson_step_resources") } ``` ### 3.8 SchoolCourse(校本课程包) ```prisma // ==================== 校本课程包 ==================== model SchoolCourse { id Int @id @default(autoincrement()) // 所属学校 tenantId Int @map("tenant_id") // 基于的课程包 sourceCourseId Int @map("source_course_id") // 基本信息 name String description String? @db.Text // 创建者(教师) createdBy Int @map("created_by") // 调整内容摘要 changesSummary String? @map("changes_summary") @db.Text // 调整详情(JSON) changesData String? @map("changes_data") @db.Text // 使用统计 usageCount Int @default(0) @map("usage_count") // 状态 status String @default("ACTIVE") // ACTIVE, ARCHIVED createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 关联 tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) sourceCourse Course @relation(fields: [sourceCourseId], references: [id]) lessons SchoolCourseLesson[] reservations SchoolCourseReservation[] @@index([tenantId]) @@index([sourceCourseId]) @@map("school_courses") } ``` ### 3.9 SchoolCourseLesson(校本课程) ```prisma // ==================== 校本课程 ==================== model SchoolCourseLesson { id Int @id @default(autoincrement()) schoolCourseId Int @map("school_course_id") sourceLessonId Int @map("source_lesson_id") // 基于的原始课程 // 调整类型 lessonType String @map("lesson_type") // 调整后的内容 objectives String? @db.Text preparation String? @db.Text extension String? @db.Text reflection String? @db.Text // 调整说明 changeNote String? @map("change_note") @db.Text // 环节数据(JSON,调整后的) stepsData String? @map("steps_data") @db.Text createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 关联 schoolCourse SchoolCourse @relation(fields: [schoolCourseId], references: [id], onDelete: Cascade) @@unique([schoolCourseId, lessonType]) @@map("school_course_lessons") } ``` ### 3.10 SchoolCourseReservation(校本课程预约) ```prisma // ==================== 校本课程预约 ==================== model SchoolCourseReservation { id Int @id @default(autoincrement()) schoolCourseId Int @map("school_course_id") // 预约信息 teacherId Int @map("teacher_id") classId Int @map("class_id") // 时间 scheduledDate DateTime @map("scheduled_date") scheduledTime String? @map("scheduled_time") // "10:00-10:30" // 状态 status String @default("PENDING") // PENDING, CONFIRMED, COMPLETED, CANCELLED // 备注 note String? @db.Text createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 关联 schoolCourse SchoolCourse @relation(fields: [schoolCourseId], references: [id], onDelete: Cascade) @@index([schoolCourseId, scheduledDate]) @@index([teacherId, scheduledDate]) @@map("school_course_reservations") } ``` --- ## 四、重构表设计 ### 4.1 Course(课程包)- 重构 ```prisma // ==================== 课程包(重构) ==================== model Course { id Int @id @default(autoincrement()) // ========== 新增字段 ========== // 主题(关联主题字典) themeId Int? @map("theme_id") // 核心内容 coreContent String? @map("core_content") @db.Text // 课程介绍(8个富文本字段) introSummary String? @map("intro_summary") @db.Text // 课程简介 introHighlights String? @map("intro_highlights") @db.Text // 课程亮点 introGoals String? @map("intro_goals") @db.Text // 课程总目标 introSchedule String? @map("intro_schedule") @db.Text // 课程内容安排 introKeyPoints String? @map("intro_key_points") @db.Text // 教学重难点 introMethods String? @map("intro_methods") @db.Text // 教学方法 introEvaluation String? @map("intro_evaluation") @db.Text // 评价方式 introNotes String? @map("intro_notes") @db.Text // 注意事项 // 排课计划参考(表格数据) scheduleRefData String? @map("schedule_ref_data") @db.Text // JSON数组 // 是否有集体课(用于判断是否配置了核心课程) hasCollectiveLesson Boolean @default(false) @map("has_collective_lesson") // ========== 保留字段 ========== name String description String? pictureBookId Int? @map("picture_book_id") pictureBookName String? @map("picture_book_name") coverImagePath String? @map("cover_image_path") // 兼容旧版:数字资源(后续迁移到CourseLesson) ebookPaths String? @map("ebook_paths") audioPaths String? @map("audio_paths") videoPaths String? @map("video_paths") otherResources String? @map("other_resources") // 兼容旧版:教学材料(后续迁移到CourseLesson) pptPath String? @map("ppt_path") pptName String? @map("ppt_name") posterPaths String? @map("poster_paths") tools String? @map("tools") studentMaterials String? @map("student_materials") // 兼容旧版:课堂计划(后续迁移到CourseLesson+LessonStep) lessonPlanData String? @map("lesson_plan_data") // 兼容旧版:延伸活动(后续迁移到领域课) activitiesData String? @map("activities_data") // 兼容旧版:测评工具(后续迁移到CourseLesson) assessmentData String? @map("assessment_data") gradeTags String @default("[]") @map("grade_tags") domainTags String @default("[]") @map("domain_tags") duration Int @default(25) status String @default("DRAFT") version String @default("1.0") submittedAt DateTime? @map("submitted_at") submittedBy Int? @map("submitted_by") reviewedAt DateTime? @map("reviewed_at") reviewedBy Int? @map("reviewed_by") reviewComment String? @map("review_comment") reviewChecklist String? @map("review_checklist") parentId Int? @map("parent_id") isLatest Boolean @default(true) usageCount Int @default(0) @map("usage_count") teacherCount Int @default(0) @map("teacher_count") avgRating Float @default(0) @map("avg_rating") createdBy Int? @map("created_by") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") publishedAt DateTime? @map("published_at") // ========== 关联 ========== theme Theme? @relation(fields: [themeId], references: [id]) // 新增关联 packageCourses CoursePackageCourse[] lessons CourseLesson[] schoolCourses SchoolCourse[] // 保留关联 resources CourseResource[] scripts CourseScript[] // 废弃但保留 activities CourseActivity[] // 废弃但保留 tenantCourses TenantCourse[] versions CourseVersion[] lessons_rel Lesson[] // 授课记录 tasks Task[] taskTemplates TaskTemplate[] schedulePlans SchedulePlan[] scheduleTemplates ScheduleTemplate[] @@index([themeId]) @@index([status]) @@map("courses") } ``` ### 4.2 Tenant(租户)- 新增关联 ```prisma // 在 Tenant 模型中新增关联 model Tenant { // ... 保留原有字段 ... // 新增关联 packages TenantPackage[] schoolCourses SchoolCourse[] // ... 保留其他关联 ... } ``` ### 4.3 CourseResource(课程资源)- 新增关联 ```prisma // 在 CourseResource 模型中新增关联 model CourseResource { // ... 保留原有字段 ... // 新增关联 stepResources LessonStepResource[] @@map("course_resources") } ``` --- ## 五、废弃表处理 ### 5.1 废弃策略 | 表名 | 废弃方式 | 说明 | |-----|---------|------| | CourseScript | 保留,标记废弃 | 数据迁移后不删除,保留历史记录 | | CourseScriptPage | 保留,标记废弃 | 数据迁移后不删除,保留历史记录 | | CourseActivity | 保留,标记废弃 | 数据迁移后不删除,保留历史记录 | ### 5.2 数据迁移说明 ``` 数据迁移映射: CourseScript → CourseLesson + LessonStep ├─ stepName → LessonStep.name ├─ teacherScript → LessonStep.content ├─ duration → LessonStep.duration ├─ objective → LessonStep.objective └─ resourceIds → LessonStepResource 关联 CourseScriptPage → LessonStep(合入) ├─ questions → LessonStep.content └─ teacherNotes → LessonStep.content CourseActivity → CourseLesson(领域课) ├─ name → CourseLesson.name ├─ activityGuide → CourseLesson.extension └─ domain → CourseLesson.lessonType ``` --- ## 六、迁移方案 ### 6.1 迁移步骤 ``` Phase 1: 创建新表(不删除旧表) ├─ 创建 Theme 表 ├─ 创建 CoursePackage 表 ├─ 创建 CoursePackageCourse 表 ├─ 创建 TenantPackage 表 ├─ 创建 CourseLesson 表 ├─ 创建 LessonStep 表 ├─ 创建 LessonStepResource 表 ├─ 创建 SchoolCourse 表 ├─ 创建 SchoolCourseLesson 表 └─ 创建 SchoolCourseReservation 表 Phase 2: 修改现有表 ├─ Course 表新增字段(themeId, coreContent, intro* 等) ├─ CourseResource 表保持不变 ├─ Tenant 表新增关联 └─ 其他表保持不变 Phase 3: 数据迁移 ├─ 初始化 Theme 字典数据 ├─ 为现有 Course 创建默认的 CourseLesson ├─ 迁移 CourseScript → CourseLesson + LessonStep ├─ 迁移 CourseActivity → CourseLesson(领域课) └─ 更新统计字段 Phase 4: 应用层适配 ├─ 更新 API 接口 ├─ 更新前端页面 └─ 测试验证 Phase 5: 清理(可选) ├─ 标记旧表为废弃 └─ 评估是否需要删除旧表数据 ``` ### 6.2 安全措施 ```typescript // 迁移前备份 async function backupBeforeMigration() { // 1. 导出关键表数据 const courses = await prisma.course.findMany(); const scripts = await prisma.courseScript.findMany(); const activities = await prisma.courseActivity.findMany(); // 2. 保存到文件 fs.writeFileSync('backup/courses.json', JSON.stringify(courses)); fs.writeFileSync('backup/scripts.json', JSON.stringify(scripts)); fs.writeFileSync('backup/activities.json', JSON.stringify(activities)); } // 迁移脚本示例 async function migrateCourseToNewStructure(courseId: number) { const course = await prisma.course.findUnique({ where: { id: courseId }, include: { scripts: { orderBy: { stepIndex: 'asc' } }, activities: true } }); if (!course) return; // 1. 创建集体课(如果有脚本数据) if (course.scripts.length > 0) { const collectiveLesson = await prisma.courseLesson.create({ data: { courseId: course.id, lessonType: 'COLLECTIVE', name: `${course.name} - 集体课`, duration: course.duration, useTemplate: true, } }); // 2. 迁移脚本为环节 for (const script of course.scripts) { await prisma.lessonStep.create({ data: { lessonId: collectiveLesson.id, name: script.stepName, content: script.teacherScript || '', duration: script.duration, objective: script.objective, resourceIds: script.resourceIds, sortOrder: script.stepIndex, } }); } } // 3. 迁移延伸活动为领域课 for (const activity of course.activities) { const lessonType = mapDomainToLessonType(activity.domain); await prisma.courseLesson.create({ data: { courseId: course.id, lessonType: lessonType, name: activity.name, duration: activity.duration || 25, extension: activity.activityGuide, sortOrder: activity.sortOrder, } }); } // 4. 更新课程包标记 await prisma.course.update({ where: { id: courseId }, data: { hasCollectiveLesson: course.scripts.length > 0 } }); } // 领域映射 function mapDomainToLessonType(domain: string | null): string { const mapping: Record = { '语言': 'LANGUAGE', '健康': 'HEALTH', '科学': 'SCIENCE', '社会': 'SOCIAL', '艺术': 'ART', }; return mapping[domain || ''] || 'LANGUAGE'; } ``` --- ## 七、API变更说明 ### 7.1 新增API | 模块 | 路径 | 说明 | |-----|------|------| | 套餐管理 | `POST /admin/packages` | 创建套餐 | | 套餐管理 | `PUT /admin/packages/:id/courses` | 添加课程包到套餐 | | 课程管理 | `POST /admin/courses/:id/lessons` | 创建课程 | | 课程管理 | `POST /admin/courses/:id/lessons/:lessonId/steps` | 创建教学环节 | | 学校端 | `POST /school/school-courses` | 创建校本课程包 | | 学校端 | `POST /school/school-courses/:id/reservations` | 预约校本课程 | ### 7.2 修改API | 模块 | 路径 | 变更说明 | |-----|------|---------| | 课程包详情 | `GET /teacher/courses/:id` | 返回新结构数据 | | 备课模式 | `GET /teacher/courses/:id/lessons` | 按课程维度获取 | | 上课模式 | `POST /teacher/lessons/start` | 支持选择课程 | --- ## 八、后续优化建议 ### 8.1 性能优化 - 为常用查询字段添加索引 - 考虑将富文本字段拆分到单独表(如果内容过大) - 使用Redis缓存热点数据 ### 8.2 扩展性考虑 - 课程类型可使用枚举定义 - 考虑支持自定义课程类型 - 校本课程包的版本管理 --- *本文档创建于 2026-02-27* *基于需求文档:17-课程包套餐重构需求.md*