数据模型重构设计
创建时间:2026-02-27
基于需求:17-课程包套餐重构需求.md
状态:设计中
一、设计原则
1.1 重构原则
| 原则 |
说明 |
| 向后兼容 |
新结构与旧结构可以共存,支持渐进式迁移 |
| 数据安全 |
迁移过程中不丢失任何现有数据 |
| 最小变更 |
保留可复用的表和字段,减少不必要的重构 |
| 可扩展性 |
新结构支持后续功能迭代 |
1.2 参考最佳实践
基于以下资源的最佳实践:
二、表结构变更概览
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(课程套餐)
// ==================== 课程套餐 ====================
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(套餐-课程包关联)
// ==================== 套餐-课程包关联 ====================
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(租户-套餐授权)
// ==================== 租户-套餐授权 ====================
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(主题字典)
// ==================== 主题字典 ====================
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(课程)
// ==================== 课程(导入课/集体课/领域课) ====================
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(教学环节)
// ==================== 教学环节 ====================
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(环节资源关联)
// ==================== 环节资源关联 ====================
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(校本课程包)
// ==================== 校本课程包 ====================
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(校本课程)
// ==================== 校本课程 ====================
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(校本课程预约)
// ==================== 校本课程预约 ====================
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(课程包)- 重构
// ==================== 课程包(重构) ====================
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(租户)- 新增关联
// 在 Tenant 模型中新增关联
model Tenant {
// ... 保留原有字段 ...
// 新增关联
packages TenantPackage[]
schoolCourses SchoolCourse[]
// ... 保留其他关联 ...
}
4.3 CourseResource(课程资源)- 新增关联
// 在 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 安全措施
// 迁移前备份
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<string, string> = {
'语言': '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