kindergarten_java/docs/design/21-数据模型重构设计.md
2026-02-28 16:41:39 +08:00

850 lines
28 KiB
Markdown
Raw Permalink 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.

# 数据模型重构设计
> 创建时间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*