kindergarten_java/docs/design/21-数据模型重构设计.md

850 lines
28 KiB
Markdown
Raw Normal View History

2026-02-28 16:41:39 +08:00
# 数据模型重构设计
> 创建时间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<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*