feat(content): v2 扩展 Elective/LessonPlan/CoursePlan 三业务域
新增 3 个业务域(10 RPC): - ElectiveService: 选修课列表/学生选课记录/选课/退课(含容量与重复校验) - LessonPlanService: 教师备课列表/学生备课列表(仅 published)/详情 - CoursePlanService: 学生课程计划列表/详情 - KnowledgeGraphService.GetKnowledgePath: 与 GetLearningPath 同实现 新增 4 张 MySQL 表(elective_courses/selections/lesson_plans/course_plans),含完整索引。 新增 11 个权限点,覆盖 admin/teacher/student/parent 四角色。 proto 由 4 Service/22 RPC 扩展至 7 Service/32 RPC,v1 全部 RPC 保持向后兼容。 修复 logger.ts pino 导入: default import 在 NodeNext ESM 下不可调用, 改用 named import(与 iam/msg/core-edu 对齐)。 Docker 本地测试全部通过(HTTP + gRPC 双协议),健康检查、 Elective/LessonPlan/CoursePlan CRUD、4 个新 gRPC Service 全部验证通过。 nextstep-v2.md 已创建,记录上下游依赖与 6 项联调待办。
This commit is contained in:
@@ -527,6 +527,78 @@ CREATE TABLE IF NOT EXISTS `content_outbox_events` (
|
||||
INDEX `idx_outbox_aggregate` (`aggregate_type`, `aggregate_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4.6 选修课目录表(v2 新增)
|
||||
CREATE TABLE IF NOT EXISTS `content_elective_courses` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`subject_id` VARCHAR(32) NOT NULL,
|
||||
`teacher_id` VARCHAR(32) NOT NULL,
|
||||
`capacity` INT NOT NULL DEFAULT 30,
|
||||
`enrolled_count` INT NOT NULL DEFAULT 0,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
`description` TEXT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_elective_subject` (`subject_id`),
|
||||
INDEX `idx_elective_teacher` (`teacher_id`),
|
||||
INDEX `idx_elective_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4.7 学生选课记录表(v2 新增)
|
||||
CREATE TABLE IF NOT EXISTS `content_elective_selections` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`student_id` VARCHAR(32) NOT NULL,
|
||||
`course_id` VARCHAR(32) NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'selected',
|
||||
`selected_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`dropped_at` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_selection_student` (`student_id`),
|
||||
INDEX `idx_selection_course` (`course_id`),
|
||||
INDEX `idx_selection_student_course` (`student_id`, `course_id`),
|
||||
INDEX `idx_selection_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4.8 备课计划表(v2 新增)
|
||||
CREATE TABLE IF NOT EXISTS `content_lesson_plans` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`teacher_id` VARCHAR(32) NOT NULL,
|
||||
`class_id` VARCHAR(32) NOT NULL,
|
||||
`subject_id` VARCHAR(32) NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
`metadata` JSON NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_lesson_plan_teacher` (`teacher_id`),
|
||||
INDEX `idx_lesson_plan_class` (`class_id`),
|
||||
INDEX `idx_lesson_plan_subject` (`subject_id`),
|
||||
INDEX `idx_lesson_plan_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4.9 课程计划表(v2 新增)
|
||||
CREATE TABLE IF NOT EXISTS `content_course_plans` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`student_id` VARCHAR(32) NOT NULL,
|
||||
`class_id` VARCHAR(32) NOT NULL,
|
||||
`subject_id` VARCHAR(32) NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`plan_type` VARCHAR(32) NOT NULL DEFAULT 'default',
|
||||
`content` TEXT NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
`metadata` JSON NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_course_plan_student` (`student_id`),
|
||||
INDEX `idx_course_plan_class` (`class_id`),
|
||||
INDEX `idx_course_plan_student_type` (`student_id`, `plan_type`),
|
||||
INDEX `idx_course_plan_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. MSG 服务(services/msg/src/**/*.schema.ts)
|
||||
|
||||
@@ -23,6 +23,7 @@ service ChapterService {
|
||||
service KnowledgeGraphService {
|
||||
rpc GetPrerequisites(GetPrerequisitesRequest) returns (KnowledgePointsResponse);
|
||||
rpc GetLearningPath(GetLearningPathRequest) returns (LearningPath);
|
||||
rpc GetKnowledgePath(GetKnowledgePathRequest) returns (LearningPath);
|
||||
rpc AddPrerequisite(AddPrerequisiteRequest) returns (Empty);
|
||||
rpc RemovePrerequisite(RemovePrerequisiteRequest) returns (Empty);
|
||||
}
|
||||
@@ -38,6 +39,24 @@ service QuestionService {
|
||||
rpc SearchQuestions(SearchQuestionsRequest) returns (SearchQuestionsResponse);
|
||||
}
|
||||
|
||||
service ElectiveService {
|
||||
rpc ListAvailableElectiveCourses(ListAvailableElectiveCoursesRequest) returns (ListElectiveCoursesResponse);
|
||||
rpc ListElectiveSelectionsByStudent(ListElectiveSelectionsByStudentRequest) returns (ListElectiveSelectionsResponse);
|
||||
rpc SelectCourse(SelectCourseRequest) returns (ElectiveSelection);
|
||||
rpc DropCourse(DropCourseRequest) returns (Empty);
|
||||
}
|
||||
|
||||
service LessonPlanService {
|
||||
rpc ListLessonPlansByTeacher(ListLessonPlansByTeacherRequest) returns (ListLessonPlansResponse);
|
||||
rpc ListLessonPlansByStudent(ListLessonPlansByStudentRequest) returns (ListLessonPlansResponse);
|
||||
rpc GetLessonPlan(GetLessonPlanRequest) returns (LessonPlan);
|
||||
}
|
||||
|
||||
service CoursePlanService {
|
||||
rpc ListCoursePlansByStudent(ListCoursePlansByStudentRequest) returns (ListCoursePlansResponse);
|
||||
rpc GetCoursePlan(GetCoursePlanRequest) returns (CoursePlan);
|
||||
}
|
||||
|
||||
message Empty {}
|
||||
|
||||
message Textbook {
|
||||
@@ -272,3 +291,124 @@ message SearchQuestionsResponse {
|
||||
int32 total = 2;
|
||||
string next_page_token = 3;
|
||||
}
|
||||
|
||||
// KnowledgeGraphService: GetKnowledgePath (按班级查询学习路径)
|
||||
message GetKnowledgePathRequest {
|
||||
string class_id = 1;
|
||||
string subject_id = 2;
|
||||
}
|
||||
|
||||
// ElectiveService messages
|
||||
message ElectiveCourse {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string subject_id = 3;
|
||||
string teacher_id = 4;
|
||||
int32 capacity = 5;
|
||||
int32 enrolled_count = 6;
|
||||
string status = 7;
|
||||
string description = 8;
|
||||
int64 created_at = 9;
|
||||
int64 updated_at = 10;
|
||||
}
|
||||
|
||||
message ElectiveSelection {
|
||||
string id = 1;
|
||||
string student_id = 2;
|
||||
string course_id = 3;
|
||||
string status = 4;
|
||||
int64 selected_at = 5;
|
||||
int64 dropped_at = 6;
|
||||
}
|
||||
|
||||
message ListAvailableElectiveCoursesRequest {
|
||||
string subject_id = 1;
|
||||
int32 page_size = 2;
|
||||
string page_token = 3;
|
||||
}
|
||||
|
||||
message ListElectiveCoursesResponse {
|
||||
repeated ElectiveCourse courses = 1;
|
||||
string next_page_token = 2;
|
||||
}
|
||||
|
||||
message ListElectiveSelectionsByStudentRequest {
|
||||
string student_id = 1;
|
||||
string status = 2;
|
||||
}
|
||||
|
||||
message ListElectiveSelectionsResponse {
|
||||
repeated ElectiveSelection selections = 1;
|
||||
}
|
||||
|
||||
message SelectCourseRequest {
|
||||
string student_id = 1;
|
||||
string course_id = 2;
|
||||
}
|
||||
|
||||
message DropCourseRequest {
|
||||
string student_id = 1;
|
||||
string course_id = 2;
|
||||
}
|
||||
|
||||
// LessonPlanService messages
|
||||
message LessonPlan {
|
||||
string id = 1;
|
||||
string teacher_id = 2;
|
||||
string class_id = 3;
|
||||
string subject_id = 4;
|
||||
string title = 5;
|
||||
string content = 6;
|
||||
string status = 7;
|
||||
google.protobuf.Struct metadata = 8;
|
||||
int64 created_at = 9;
|
||||
int64 updated_at = 10;
|
||||
}
|
||||
|
||||
message ListLessonPlansByTeacherRequest {
|
||||
string teacher_id = 1;
|
||||
string class_id = 2;
|
||||
string subject_id = 3;
|
||||
}
|
||||
|
||||
message ListLessonPlansByStudentRequest {
|
||||
string student_id = 1;
|
||||
string class_id = 2;
|
||||
}
|
||||
|
||||
message ListLessonPlansResponse {
|
||||
repeated LessonPlan lesson_plans = 1;
|
||||
}
|
||||
|
||||
message GetLessonPlanRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// CoursePlanService messages
|
||||
message CoursePlan {
|
||||
string id = 1;
|
||||
string student_id = 2;
|
||||
string class_id = 3;
|
||||
string subject_id = 4;
|
||||
string title = 5;
|
||||
string plan_type = 6;
|
||||
string content = 7;
|
||||
string status = 8;
|
||||
google.protobuf.Struct metadata = 9;
|
||||
int64 created_at = 10;
|
||||
int64 updated_at = 11;
|
||||
}
|
||||
|
||||
message ListCoursePlansByStudentRequest {
|
||||
string student_id = 1;
|
||||
string class_id = 2;
|
||||
string plan_type = 3;
|
||||
}
|
||||
|
||||
message ListCoursePlansResponse {
|
||||
repeated CoursePlan course_plans = 1;
|
||||
}
|
||||
|
||||
message GetCoursePlanRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
341
services/content/docs/nextstep-v2.md
Normal file
341
services/content/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# content 下一步工作与上下游依赖(v2)
|
||||
|
||||
> 模块:content(内容域服务,7 Service / 32 RPC,端口 HTTP 3005 / gRPC 50054)
|
||||
> 更新日期:2026-07-14(v2 全部完成,Docker 本地测试通过)
|
||||
> 关联文档:[nextstep.md](./nextstep.md)、[02-architecture-design.md](./02-architecture-design.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. v2 完成项
|
||||
|
||||
content 模块在 v1 基础上扩展了 3 个业务域(Elective / LessonPlan / CoursePlan)+ 1 个查询入口(GetKnowledgePath),新增 10 个 RPC、4 张 MySQL 表、11 个权限点,HTTP + gRPC 双协议本地 Docker 全部测试通过。
|
||||
|
||||
### 1.1 v2 服务能力概览
|
||||
|
||||
| Service | RPC 数 | v1 已有 | v2 新增 | 核心能力 |
|
||||
| --------------------- | ------ | ------- | ------- | ------------------------------------------------------ |
|
||||
| TextbookService | 5 | 5 | 0 | 教材 CRUD、归档、版本 |
|
||||
| ChapterService | 5 | 5 | 0 | 章节 CRUD、目录树 |
|
||||
| KnowledgeGraphService | 5 | 4 | 1 | 知识图谱、学习路径、前置依赖、可视化、GetKnowledgePath |
|
||||
| QuestionService | 8 | 8 | 0 | 题目 CRUD、ES 检索、审核状态机、AI 出题 |
|
||||
| **ElectiveService** | 4 | 0 | 4 | 选修课列表 / 学生选课记录 / 选课 / 退课 |
|
||||
| **LessonPlanService** | 3 | 0 | 3 | 教师备课列表 / 学生备课列表 / 详情 |
|
||||
| **CoursePlanService** | 2 | 0 | 2 | 学生课程计划列表 / 详情 |
|
||||
| **合计** | **32** | 22 | **10** | **7 Service** |
|
||||
|
||||
### 1.2 v2 新增 RPC 列表
|
||||
|
||||
| Service | RPC | 用途 |
|
||||
| --------------------- | ------------------------------- | -------------------------------------------------------------------- |
|
||||
| KnowledgeGraphService | GetKnowledgePath | 知识图谱路径查询(与 GetLearningPath 同实现,符合上游 BFF 命名约定) |
|
||||
| ElectiveService | ListAvailableElectiveCourses | 列出可选选修课(status=open 且有剩余容量) |
|
||||
| ElectiveService | ListElectiveSelectionsByStudent | 查询学生已选选修课 |
|
||||
| ElectiveService | SelectCourse | 学生选课(含容量校验 + 重复选课校验) |
|
||||
| ElectiveService | DropCourse | 学生退课(同步 enrolled_count 减 1) |
|
||||
| LessonPlanService | ListLessonPlansByTeacher | 教师视角备课列表 |
|
||||
| LessonPlanService | ListLessonPlansByStudent | 学生视角备课列表(仅 status=published) |
|
||||
| LessonPlanService | GetLessonPlan | 备课详情 |
|
||||
| CoursePlanService | ListCoursePlansByStudent | 学生课程计划列表 |
|
||||
| CoursePlanService | GetCoursePlan | 课程计划详情 |
|
||||
|
||||
### 1.3 v2 新增数据表
|
||||
|
||||
| 表名 | 用途 | 索引 |
|
||||
| --------------------------- | ------------ | ------------------------------------------ |
|
||||
| content_elective_courses | 选修课主表 | subject_id / teacher_id / status |
|
||||
| content_elective_selections | 学生选课记录 | student_id / course_id / status |
|
||||
| content_lesson_plans | 教师备课计划 | teacher_id / class_id / status |
|
||||
| content_course_plans | 学生课程计划 | student_id / class_id / plan_type / status |
|
||||
|
||||
迁移 SQL:[v2-migration.sql](./v2-migration.sql)
|
||||
|
||||
### 1.4 v2 新增权限点
|
||||
|
||||
| 权限常量 | admin | teacher | student | parent |
|
||||
| -------------------------- | ----- | ------- | ------- | ------ |
|
||||
| CONTENT_ELECTIVE_CREATE | ✅ | ✅ | ❌ | ❌ |
|
||||
| CONTENT_ELECTIVE_READ | ✅ | ✅ | ✅ | ✅ |
|
||||
| CONTENT_ELECTIVE_SELECT | ❌ | ❌ | ✅ | ❌ |
|
||||
| CONTENT_LESSON_PLAN_CREATE | ✅ | ✅ | ❌ | ❌ |
|
||||
| CONTENT_LESSON_PLAN_READ | ✅ | ✅ | ✅ | ✅ |
|
||||
| CONTENT_LESSON_PLAN_UPDATE | ✅ | ✅ | ❌ | ❌ |
|
||||
| CONTENT_LESSON_PLAN_DELETE | ✅ | ✅ | ❌ | ❌ |
|
||||
| CONTENT_COURSE_PLAN_CREATE | ✅ | ✅ | ❌ | ❌ |
|
||||
| CONTENT_COURSE_PLAN_READ | ✅ | ✅ | ✅ | ✅ |
|
||||
| CONTENT_COURSE_PLAN_UPDATE | ✅ | ✅ | ❌ | ❌ |
|
||||
| CONTENT_COURSE_PLAN_DELETE | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
### 1.5 v2 Docker 本地测试结果(2026-07-14)
|
||||
|
||||
测试环境:本地 Docker(edu-mysql + edu-neo4j + edu-es + edu-kafka + edu-redis + edu-content-test)
|
||||
|
||||
```
|
||||
镜像:edu/content:test
|
||||
容器:edu-content-test(DEV_MODE=true,HTTP 3105 / gRPC 51054)
|
||||
|
||||
测试 1:健康检查
|
||||
GET /healthz → {"status":"ok"} ✅
|
||||
GET /readyz → {"status":"ok","dependencies":[mysql:ok, neo4j:ok, kafka:ok, outbox:ok, elasticsearch:ok]} ✅
|
||||
|
||||
测试 2:Elective CRUD(全部通过)
|
||||
POST /electives/courses → 创建选修课 ✅ (id=rtssmkviljmmkpa04npvklq0)
|
||||
GET /electives/courses → 列表 ✅ (count=1)
|
||||
POST /electives/select → 学生选课 ✅ (id=js987vhi63xnnxyxov2yttbg)
|
||||
GET /electives/selections → 学生选课记录 ✅ (count=1, status=selected)
|
||||
POST /electives/drop → 退课 ✅
|
||||
|
||||
测试 3:LessonPlan CRUD(全部通过)
|
||||
POST /lesson-plans → 创建备课 ✅ (id=gxgozqgr059y4w0v9ndhm6mt, status=draft)
|
||||
PUT /lesson-plans/:id → 发布备课 ✅ (status=published)
|
||||
GET /lesson-plans?teacherId=.. → 教师列表 ✅ (count=1)
|
||||
GET /lesson-plans?studentId=..&classId=.. → 学生列表(仅 published) ✅ (count=1)
|
||||
GET /lesson-plans/:id → 详情 ✅
|
||||
DELETE /lesson-plans/:id → 删除 ✅
|
||||
|
||||
测试 4:CoursePlan CRUD(全部通过)
|
||||
POST /course-plans → 创建课程计划 ✅ (id=za9r3arn8hetr5nwjao9dgre, status=active)
|
||||
GET /course-plans?studentId=.. → 学生列表 ✅ (count=1)
|
||||
GET /course-plans/:id → 详情 ✅
|
||||
PUT /course-plans/:id → 更新 ✅ (status=archived)
|
||||
DELETE /course-plans/:id → 删除 ✅
|
||||
|
||||
测试 5:gRPC 4 个新 Service(全部通过)
|
||||
ElectiveService.ListAvailableElectiveCourses → ✅ 返回 1 个课程
|
||||
LessonPlanService.ListLessonPlansByTeacher → ✅ 返回空(已删除测试数据)
|
||||
CoursePlanService.ListCoursePlansByStudent → ✅ 返回空(已删除测试数据)
|
||||
KnowledgeGraphService.GetKnowledgePath → ✅ 返回 1 个知识点
|
||||
|
||||
测试 6:gRPC Controller 注册(启动日志验证)
|
||||
7 个 gRPC controller 全部注册 ✅
|
||||
8 个 HTTP controller 全部注册 ✅
|
||||
Neo4jSyncWorker / EsSyncWorker / OutboxPublisher 全部启动 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 上游需求满足情况(content → 上游 BFF)
|
||||
|
||||
content 已实现上游 3 个 BFF 在 nextstep-v2.md 中提出的全部 v2 依赖。
|
||||
|
||||
### 2.1 teacher-bff(ai03 负责)
|
||||
|
||||
| # | teacher-bff 依赖项 | content 实现 RPC | 状态 |
|
||||
| --- | ---------------------------------------- | -------------------------------------------- | ---- |
|
||||
| 1 | gRPC `GetKnowledgePath(classId)` :50054 | `KnowledgeGraphService.GetKnowledgePath` | ✅ |
|
||||
| 2 | gRPC `ListTextbooks()` :50054 | `TextbookService.ListTextbooks`(v1 已有) | ✅ |
|
||||
| 3 | gRPC `ListLessonPlans(teacherId)` :50054 | `LessonPlanService.ListLessonPlansByTeacher` | ✅ |
|
||||
|
||||
### 2.2 student-bff(ai04 负责)
|
||||
|
||||
| # | student-bff 依赖项 | content 实现 RPC | 状态 |
|
||||
| --- | ------------------------------------------------- | -------------------------------------------------- | ---- |
|
||||
| 1 | `TextbookService.ListTextbooks` | `TextbookService.ListTextbooks`(v1 已有) | ✅ |
|
||||
| 2 | `ChapterService.ListChapters` | `ChapterService.ListChapters`(v1 已有) | ✅ |
|
||||
| 3 | `KnowledgeGraphService.GetLearningPath` | `KnowledgeGraphService.GetLearningPath`(v1 已有) | ✅ |
|
||||
| 4 | `ElectiveService.ListElectiveSelectionsByStudent` | `ElectiveService.ListElectiveSelectionsByStudent` | ✅ |
|
||||
| 5 | `ElectiveService.ListAvailableElectiveCourses` | `ElectiveService.ListAvailableElectiveCourses` | ✅ |
|
||||
| 6 | `ElectiveService.SelectCourse` | `ElectiveService.SelectCourse` | ✅ |
|
||||
| 7 | `ElectiveService.DropCourse` | `ElectiveService.DropCourse` | ✅ |
|
||||
| 8 | `LessonPlanService.ListLessonPlansByStudent` | `LessonPlanService.ListLessonPlansByStudent` | ✅ |
|
||||
| 9 | `LessonPlanService.GetLessonPlan` | `LessonPlanService.GetLessonPlan` | ✅ |
|
||||
| 10 | `CoursePlanService.ListCoursePlansByStudent` | `CoursePlanService.ListCoursePlansByStudent` | ✅ |
|
||||
| 11 | `CoursePlanService.GetCoursePlan` | `CoursePlanService.GetCoursePlan` | ✅ |
|
||||
|
||||
### 2.3 parent-bff(ai05 负责)
|
||||
|
||||
| # | parent-bff 依赖项 | content 实现 RPC | 状态 |
|
||||
| --- | ---------------------------- | -------------------------------------------------------------- | ---- |
|
||||
| 1 | `CoursePlanService.List/Get` | `CoursePlanService.ListCoursePlansByStudent` + `GetCoursePlan` | ✅ |
|
||||
| 2 | `LessonPlanService.List/Get` | `LessonPlanService.ListLessonPlansByStudent` + `GetLessonPlan` | ✅ |
|
||||
| 3 | `ElectiveService.List` | `ElectiveService.ListElectiveSelectionsByStudent` | ✅ |
|
||||
|
||||
### 2.4 api-gateway(ai01 负责)
|
||||
|
||||
| 路由 | 转发目标 | 状态 |
|
||||
| ------------------------------- | ------------ | ------------------------------ |
|
||||
| `/api/v1/textbooks/*` | content:3005 | ✅ v1 已配置 |
|
||||
| `/api/v1/chapters/*` | content:3005 | ✅ v1 已配置 |
|
||||
| `/api/v1/knowledge-points/*` | content:3005 | ✅ v1 已配置 |
|
||||
| `/api/v1/questions/*` | content:3005 | ✅ v1 已配置 |
|
||||
| **`/api/v1/electives/*`** | content:3005 | ⏳ 待 api-gateway 补充 v2 路由 |
|
||||
| **`/api/v1/lesson-plans/*`** | content:3005 | ⏳ 待 api-gateway 补充 v2 路由 |
|
||||
| **`/api/v1/course-plans/*`** | content:3005 | ⏳ 待 api-gateway 补充 v2 路由 |
|
||||
| **`/api/v1/knowledge-graph/*`** | content:3005 | ⏳ 待 api-gateway 补充 v2 路由 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 下游需求(content 需要谁)
|
||||
|
||||
### 3.1 MySQL(基础设施)— ✅ 已就绪
|
||||
|
||||
v2 新增 4 张表已通过 `v2-migration.sql` 在本地 Docker MySQL 中创建。
|
||||
|
||||
### 3.2 Neo4j / Kafka / Elasticsearch / Redis — ✅ 已就绪
|
||||
|
||||
v2 复用 v1 基础设施,无新增依赖。
|
||||
|
||||
### 3.3 ai 服务(ai12 负责)— ⏳ 待联调
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ------------------------------ | ----------------------------------- | ----------------- |
|
||||
| 1 | gRPC `GenerateQuestion` :50058 | AI 批量出题(QuestionService 联调) | ⏳ 待 ai 服务就绪 |
|
||||
|
||||
### 3.4 iam 服务(ai06 负责)— ⏳ 待联调
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | -------------------------------------------- | ------------------------------------------- | ------------------ |
|
||||
| 1 | JWT 公钥 / JWKS 端点 | 生产模式 JWT 校验(DEV_MODE=true 时已绕过) | ⏳ 待 iam 服务就绪 |
|
||||
| 2 | 用户角色信息(admin/teacher/student/parent) | 权限守卫根据角色判断访问权限 | ⏳ 待 iam 服务就绪 |
|
||||
|
||||
### 3.5 core-edu 服务(ai07 负责)— ⏳ 待联调(v3+)
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| 1 | 班级学生名单查询 | LessonPlan 学生视角:按 classId 过滤学生所在班级的备课计划。当前实现需前端在 query 中显式传 classId,未来可由 core-edu 提供学生→班级映射后自动解析 | ⏳ v3+ |
|
||||
| 2 | 教师任教科目查询 | LessonPlan/CoursePlan 按 subjectId 过滤时,前端需显式传参。未来可由 core-edu 提供教师→科目映射 | ⏳ v3+ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 环境变量(v2 无新增)
|
||||
|
||||
v2 沿用 v1 全部环境变量,详见 [nextstep.md §4](./nextstep.md#4-环境变量清单)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 待上下游完成的工作
|
||||
|
||||
### 5.1 给 api-gateway(ai01)
|
||||
|
||||
**v2 路由补充**:在 `services/api-gateway` 的路由配置中新增以下 4 条反向代理规则,剥离 `/api` 前缀后转发到 `content:3005`:
|
||||
|
||||
| 路由 | 转发目标 | 备注 |
|
||||
| --------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `/api/v1/electives/*` | content:3005 | 选修课管理(含 /electives/courses、/electives/selections、/electives/select、/electives/drop) |
|
||||
| `/api/v1/lesson-plans/*` | content:3005 | 备课计划管理 |
|
||||
| `/api/v1/course-plans/*` | content:3005 | 课程计划管理 |
|
||||
| `/api/v1/knowledge-graph/*` | content:3005 | 知识图谱可视化(v1 已有路由 `/api/v1/knowledge-points`,但 `/knowledge-graph/visualization` 需补充) |
|
||||
|
||||
### 5.2 给 teacher-bff(ai03)
|
||||
|
||||
**content gRPC 客户端联调**:
|
||||
|
||||
1. 配置 `CONTENT_GRPC_TARGET=content:50054`(已配置)
|
||||
2. 在 `services/teacher-bff/src/clients/content/content-grpc.client.ts` 中确认 3 个 RPC 调用:
|
||||
- `KnowledgeGraphService.GetKnowledgePath`(request: `{ subject_id }`,response: `LearningPath`)
|
||||
- `TextbookService.ListTextbooks`(v1 已有)
|
||||
- `LessonPlanService.ListLessonPlansByTeacher`(request: `{ teacher_id }`,response: `ListLessonPlansResponse`)
|
||||
3. 移除 mock 降级逻辑,切换到真实 gRPC 调用
|
||||
|
||||
### 5.3 给 student-bff(ai04)
|
||||
|
||||
**content gRPC 客户端联调**:
|
||||
|
||||
1. 配置 `CONTENT_GRPC_TARGET=content:50054`
|
||||
2. 确认 11 个 content RPC 调用(详见 §2.2),重点验证 v2 新增 8 个 RPC:
|
||||
- ElectiveService(4 个):`ListAvailableElectiveCourses` / `ListElectiveSelectionsByStudent` / `SelectCourse` / `DropCourse`
|
||||
- LessonPlanService(2 个):`ListLessonPlansByStudent` / `GetLessonPlan`
|
||||
- CoursePlanService(2 个):`ListCoursePlansByStudent` / `GetCoursePlan`
|
||||
3. 移除 mock 降级逻辑,切换到真实 gRPC 调用
|
||||
|
||||
### 5.4 给 parent-bff(ai05)
|
||||
|
||||
**content gRPC 客户端联调**:
|
||||
|
||||
1. 配置 `CONTENT_GRPC_TARGET=content:50054`
|
||||
2. 确认 6 个 content RPC 调用(详见 §2.3):
|
||||
- CoursePlanService.ListCoursePlansByStudent + GetCoursePlan
|
||||
- LessonPlanService.ListLessonPlansByStudent + GetLessonPlan
|
||||
- ElectiveService.ListElectiveSelectionsByStudent
|
||||
3. 移除 mock 降级逻辑,切换到真实 gRPC 调用
|
||||
|
||||
### 5.5 给 iam(ai06)
|
||||
|
||||
**生产 JWT 联调**:
|
||||
|
||||
content 在生产模式下需要 iam 的 JWKS 端点校验 JWT。当前 `DEV_MODE=true` 已绕过校验,使用预定义角色。iam 就绪后:
|
||||
|
||||
1. 提供 JWKS 端点 URL(配置 `JWT_JWKS_URI`)
|
||||
2. JWT payload 中包含 `role` 字段(admin/teacher/student/parent)
|
||||
3. content 的 `permission.guard.ts` 已支持上述 4 种角色 + 11 个 v2 权限点
|
||||
|
||||
### 5.6 给 ai 服务(ai12)
|
||||
|
||||
**AI 出题联调**:
|
||||
|
||||
content 的 `QuestionService.BatchCreateQuestions` 已预留 AI 出题入口,需要 ai 服务实现:
|
||||
|
||||
1. gRPC `GenerateQuestion` :50058
|
||||
2. 输入:知识点 ID + 题目数量 + 难度
|
||||
3. 输出:题目数组(题干 + 选项 + 答案 + 解析)
|
||||
4. content 配置 `AI_GRPC_TARGET=ai:50058`
|
||||
|
||||
---
|
||||
|
||||
## 6. 契约变更说明
|
||||
|
||||
### 6.1 proto 变更
|
||||
|
||||
**文件**:`packages/shared-proto/proto/content.proto`
|
||||
|
||||
**v2 新增**:
|
||||
|
||||
- 1 个 RPC(`KnowledgeGraphService.GetKnowledgePath`)
|
||||
- 3 个 Service(`ElectiveService` / `LessonPlanService` / `CoursePlanService`)
|
||||
- 9 个新 RPC
|
||||
- 13 个新 message(ElectiveCourse / ElectiveSelection / LessonPlan / CoursePlan 及其 Request/Response)
|
||||
|
||||
**向后兼容**:v1 全部 22 个 RPC 保持不变,无破坏性变更。
|
||||
|
||||
### 6.2 数据库变更
|
||||
|
||||
**文件**:`infra/init-sql/02-all-services-schema.sql`(已更新 4.6-4.9 节)+ `services/content/docs/v2-migration.sql`
|
||||
|
||||
**v2 新增 4 张表**:见 §1.3。
|
||||
|
||||
### 6.3 权限变更
|
||||
|
||||
**文件**:`services/content/src/middleware/permission.guard.ts`
|
||||
|
||||
**v2 新增 11 个权限点**:见 §1.4。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键文件路径
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------------------------------------------------------- | -------------------------------------- |
|
||||
| `packages/shared-proto/proto/content.proto` | gRPC 契约(v2: 32 RPC / 7 Service) |
|
||||
| `services/content/src/electives/` | 选修课模块(schema/dto/repo/svc/ctrl) |
|
||||
| `services/content/src/lesson-plans/` | 备课计划模块 |
|
||||
| `services/content/src/course-plans/` | 课程计划模块 |
|
||||
| `services/content/src/grpc/elective.grpc.controller.ts` | 选修课 gRPC controller |
|
||||
| `services/content/src/grpc/lesson-plan.grpc.controller.ts` | 备课计划 gRPC controller |
|
||||
| `services/content/src/grpc/course-plan.grpc.controller.ts` | 课程计划 gRPC controller |
|
||||
| `services/content/src/grpc/knowledge-graph.grpc.controller.ts` | 知识图谱 gRPC controller(v2 扩展) |
|
||||
| `services/content/src/grpc/grpc-types.ts` | gRPC TypeScript 类型定义 |
|
||||
| `services/content/src/middleware/permission.guard.ts` | 权限守卫(v2 新增 11 权限) |
|
||||
| `services/content/docs/v2-migration.sql` | v2 数据库迁移 SQL |
|
||||
| `infra/init-sql/02-all-services-schema.sql` | 全量 schema(含 v2 新表) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 剩余工作
|
||||
|
||||
| # | 工作项 | 阶段 | 状态 |
|
||||
| ------------------------------ | ------------------------------------------------------ | ------- | ---------------- |
|
||||
| 1 | Elective / LessonPlan / CoursePlan 三业务域实现 | v2 | ✅ 完成 |
|
||||
| 4 张新表 + 迁移 SQL | v2 | ✅ 完成 |
|
||||
| 11 个新权限点 + 角色映射 | v2 | ✅ 完成 |
|
||||
| GetKnowledgePath RPC | v2 | ✅ 完成 |
|
||||
| HTTP + gRPC 双协议 Docker 测试 | v2 | ✅ 完成 |
|
||||
| 2 | api-gateway 补充 4 条 v2 路由 | v2 联调 | ⏳ 待 ai01 |
|
||||
| 3 | teacher-bff / student-bff / parent-bff 切换到真实 gRPC | v2 联调 | ⏳ 待 ai03/04/05 |
|
||||
| 4 | iam 生产 JWT 联调 | v2 联调 | ⏳ 待 ai06 |
|
||||
| 5 | ai 服务 AI 出题联调(BatchCreateQuestions) | 联调 | ⏳ 待 ai12 |
|
||||
| 6 | core-edu 班级/科目映射(学生视角自动解析 classId) | v3+ | ⏳ 待 ai07 |
|
||||
|
||||
content 模块 v2 自身功能已全部完成,剩余工作均为上下游联调。
|
||||
|
||||
---
|
||||
|
||||
**本文件由 content 模块维护,上下游工作项请各负责 AI 完成后通知更新状态。**
|
||||
69
services/content/docs/v2-migration.sql
Normal file
69
services/content/docs/v2-migration.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
-- content v2 新增表(选修课/备课计划/课程计划)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `content_elective_courses` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`subject_id` VARCHAR(32) NOT NULL,
|
||||
`teacher_id` VARCHAR(32) NOT NULL,
|
||||
`capacity` INT NOT NULL DEFAULT 30,
|
||||
`enrolled_count` INT NOT NULL DEFAULT 0,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
`description` TEXT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_elective_subject` (`subject_id`),
|
||||
INDEX `idx_elective_teacher` (`teacher_id`),
|
||||
INDEX `idx_elective_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `content_elective_selections` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`student_id` VARCHAR(32) NOT NULL,
|
||||
`course_id` VARCHAR(32) NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'selected',
|
||||
`selected_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`dropped_at` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_selection_student` (`student_id`),
|
||||
INDEX `idx_selection_course` (`course_id`),
|
||||
INDEX `idx_selection_student_course` (`student_id`, `course_id`),
|
||||
INDEX `idx_selection_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `content_lesson_plans` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`teacher_id` VARCHAR(32) NOT NULL,
|
||||
`class_id` VARCHAR(32) NOT NULL,
|
||||
`subject_id` VARCHAR(32) NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
`metadata` JSON NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_lesson_plan_teacher` (`teacher_id`),
|
||||
INDEX `idx_lesson_plan_class` (`class_id`),
|
||||
INDEX `idx_lesson_plan_subject` (`subject_id`),
|
||||
INDEX `idx_lesson_plan_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `content_course_plans` (
|
||||
`id` VARCHAR(32) NOT NULL,
|
||||
`student_id` VARCHAR(32) NOT NULL,
|
||||
`class_id` VARCHAR(32) NOT NULL,
|
||||
`subject_id` VARCHAR(32) NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`plan_type` VARCHAR(32) NOT NULL DEFAULT 'default',
|
||||
`content` TEXT NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
`metadata` JSON NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_course_plan_student` (`student_id`),
|
||||
INDEX `idx_course_plan_class` (`class_id`),
|
||||
INDEX `idx_course_plan_student_type` (`student_id`, `plan_type`),
|
||||
INDEX `idx_course_plan_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -4,6 +4,9 @@ import { TextbooksModule } from "./textbooks/textbooks.module.js";
|
||||
import { ChaptersModule } from "./chapters/chapters.module.js";
|
||||
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
|
||||
import { QuestionsModule } from "./questions/questions.module.js";
|
||||
import { ElectivesModule } from "./electives/electives.module.js";
|
||||
import { LessonPlansModule } from "./lesson-plans/lesson-plans.module.js";
|
||||
import { CoursePlansModule } from "./course-plans/course-plans.module.js";
|
||||
import { GrpcModule } from "./grpc/grpc.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { OutboxModule } from "./shared/outbox/outbox.module.js";
|
||||
@@ -16,6 +19,9 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
ChaptersModule,
|
||||
KnowledgePointsModule,
|
||||
QuestionsModule,
|
||||
ElectivesModule,
|
||||
LessonPlansModule,
|
||||
CoursePlansModule,
|
||||
GrpcModule,
|
||||
HealthModule,
|
||||
OutboxModule,
|
||||
|
||||
75
services/content/src/course-plans/course-plans.controller.ts
Normal file
75
services/content/src/course-plans/course-plans.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { CoursePlansService } from "./course-plans.service.js";
|
||||
import type { CoursePlan } from "./course-plans.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
createCoursePlanSchema,
|
||||
updateCoursePlanSchema,
|
||||
listCoursePlansByStudentSchema,
|
||||
} from "./course-plans.dto.js";
|
||||
|
||||
@Controller("course-plans")
|
||||
export class CoursePlansController {
|
||||
constructor(private readonly service: CoursePlansService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_CREATE)
|
||||
async create(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const input = createCoursePlanSchema.parse(body);
|
||||
const result = await this.service.create(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_READ)
|
||||
async list(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: CoursePlan[] }> {
|
||||
const input = listCoursePlansByStudentSchema.parse(query);
|
||||
const data = await this.service.listByStudent(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_READ)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: CoursePlan }> {
|
||||
const data = await this.service.getById(id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
const input = updateCoursePlanSchema.parse(body);
|
||||
await this.service.update(id, input);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_DELETE)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.service.delete(id);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
}
|
||||
31
services/content/src/course-plans/course-plans.dto.ts
Normal file
31
services/content/src/course-plans/course-plans.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createCoursePlanSchema = z.object({
|
||||
studentId: z.string().min(1).max(32),
|
||||
classId: z.string().min(1).max(32),
|
||||
subjectId: z.string().min(1).max(32),
|
||||
title: z.string().min(1).max(255),
|
||||
planType: z.string().min(1).max(32).optional().default("default"),
|
||||
content: z.string().min(1),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const updateCoursePlanSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
planType: z.string().min(1).max(32).optional(),
|
||||
status: z.enum(["active", "archived"]).optional(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const listCoursePlansByStudentSchema = z.object({
|
||||
studentId: z.string().min(1).max(32),
|
||||
classId: z.string().optional(),
|
||||
planType: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateCoursePlanDto = z.infer<typeof createCoursePlanSchema>;
|
||||
export type UpdateCoursePlanDto = z.infer<typeof updateCoursePlanSchema>;
|
||||
export type ListCoursePlansByStudentDto = z.infer<
|
||||
typeof listCoursePlansByStudentSchema
|
||||
>;
|
||||
10
services/content/src/course-plans/course-plans.module.ts
Normal file
10
services/content/src/course-plans/course-plans.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CoursePlansController } from "./course-plans.controller.js";
|
||||
import { CoursePlansService } from "./course-plans.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [CoursePlansController],
|
||||
providers: [CoursePlansService],
|
||||
exports: [CoursePlansService],
|
||||
})
|
||||
export class CoursePlansModule {}
|
||||
51
services/content/src/course-plans/course-plans.repository.ts
Normal file
51
services/content/src/course-plans/course-plans.repository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
coursePlans,
|
||||
type CoursePlan,
|
||||
type NewCoursePlan,
|
||||
} from "./course-plans.schema.js";
|
||||
|
||||
export class CoursePlansRepository {
|
||||
async findById(id: string): Promise<CoursePlan | undefined> {
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(coursePlans)
|
||||
.where(eq(coursePlans.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findByStudent(query: {
|
||||
studentId: string;
|
||||
classId?: string;
|
||||
planType?: string;
|
||||
}): Promise<CoursePlan[]> {
|
||||
const db = getDb();
|
||||
const conditions = [eq(coursePlans.studentId, query.studentId)];
|
||||
if (query.classId) {
|
||||
conditions.push(eq(coursePlans.classId, query.classId));
|
||||
}
|
||||
if (query.planType) {
|
||||
conditions.push(eq(coursePlans.planType, query.planType));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(coursePlans)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
async create(data: NewCoursePlan): Promise<void> {
|
||||
await getDb().insert(coursePlans).values(data);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewCoursePlan>): Promise<void> {
|
||||
await getDb().update(coursePlans).set(data).where(eq(coursePlans.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await getDb().delete(coursePlans).where(eq(coursePlans.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export const coursePlansRepository = new CoursePlansRepository();
|
||||
38
services/content/src/course-plans/course-plans.schema.ts
Normal file
38
services/content/src/course-plans/course-plans.schema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
timestamp,
|
||||
text,
|
||||
json,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
// 课程计划:学生的个性化课程计划(按学科/类型)
|
||||
export const coursePlans = mysqlTable(
|
||||
"content_course_plans",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
studentId: varchar("student_id", { length: 32 }).notNull(),
|
||||
classId: varchar("class_id", { length: 32 }).notNull(),
|
||||
subjectId: varchar("subject_id", { length: 32 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
planType: varchar("plan_type", { length: 32 }).notNull().default("default"),
|
||||
content: text("content").notNull(),
|
||||
status: varchar("status", { length: 32 }).notNull().default("active"),
|
||||
metadata: json("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
studentIdx: index("idx_course_plan_student").on(table.studentId),
|
||||
classIdx: index("idx_course_plan_class").on(table.classId),
|
||||
studentTypeIdx: index("idx_course_plan_student_type").on(
|
||||
table.studentId,
|
||||
table.planType,
|
||||
),
|
||||
statusIdx: index("idx_course_plan_status").on(table.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export type CoursePlan = typeof coursePlans.$inferSelect;
|
||||
export type NewCoursePlan = typeof coursePlans.$inferInsert;
|
||||
69
services/content/src/course-plans/course-plans.service.ts
Normal file
69
services/content/src/course-plans/course-plans.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { coursePlansRepository } from "./course-plans.repository.js";
|
||||
import type { CoursePlan, NewCoursePlan } from "./course-plans.schema.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateCoursePlanInput {
|
||||
studentId: string;
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
planType?: string;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateCoursePlanInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
planType?: string;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CoursePlansService {
|
||||
async create(input: CreateCoursePlanInput): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const record: NewCoursePlan = {
|
||||
id,
|
||||
studentId: input.studentId,
|
||||
classId: input.classId,
|
||||
subjectId: input.subjectId,
|
||||
title: input.title,
|
||||
planType: input.planType ?? "default",
|
||||
content: input.content,
|
||||
status: "active",
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
await coursePlansRepository.create(record);
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<CoursePlan> {
|
||||
const plan = await coursePlansRepository.findById(id);
|
||||
if (!plan) {
|
||||
throw new NotFoundError("CoursePlan", id);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
async listByStudent(query: {
|
||||
studentId: string;
|
||||
classId?: string;
|
||||
planType?: string;
|
||||
}): Promise<CoursePlan[]> {
|
||||
return coursePlansRepository.findByStudent(query);
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateCoursePlanInput): Promise<void> {
|
||||
await this.getById(id);
|
||||
await coursePlansRepository.update(id, data);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await coursePlansRepository.delete(id);
|
||||
}
|
||||
}
|
||||
84
services/content/src/electives/electives.controller.ts
Normal file
84
services/content/src/electives/electives.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { ElectivesService } from "./electives.service.js";
|
||||
import type { ElectiveCourse, ElectiveSelection } from "./electives.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
createElectiveCourseSchema,
|
||||
listAvailableElectiveCoursesSchema,
|
||||
listElectiveSelectionsSchema,
|
||||
selectCourseSchema,
|
||||
dropCourseSchema,
|
||||
} from "./electives.dto.js";
|
||||
|
||||
@Controller("electives")
|
||||
export class ElectivesController {
|
||||
constructor(private readonly service: ElectivesService) {}
|
||||
|
||||
@Post("courses")
|
||||
@RequirePermission(Permissions.CONTENT_ELECTIVE_CREATE)
|
||||
async createCourse(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const input = createElectiveCourseSchema.parse(body);
|
||||
const result = await this.service.createCourse(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get("courses")
|
||||
@RequirePermission(Permissions.CONTENT_ELECTIVE_READ)
|
||||
async listAvailableCourses(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: ElectiveCourse[] }> {
|
||||
const input = listAvailableElectiveCoursesSchema.parse(query);
|
||||
const data = await this.service.listAvailableCourses(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Get("courses/:id")
|
||||
@RequirePermission(Permissions.CONTENT_ELECTIVE_READ)
|
||||
async getCourse(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: ElectiveCourse }> {
|
||||
const data = await this.service.getCourseById(id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Get("selections")
|
||||
@RequirePermission(Permissions.CONTENT_ELECTIVE_READ)
|
||||
async listSelections(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: ElectiveSelection[] }> {
|
||||
const input = listElectiveSelectionsSchema.parse(query);
|
||||
const data = await this.service.listSelectionsByStudent(
|
||||
input.studentId,
|
||||
input.status,
|
||||
);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Post("select")
|
||||
@RequirePermission(Permissions.CONTENT_ELECTIVE_SELECT)
|
||||
async selectCourse(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const input = selectCourseSchema.parse(body);
|
||||
const result = await this.service.selectCourse(
|
||||
input.studentId,
|
||||
input.courseId,
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Post("drop")
|
||||
@RequirePermission(Permissions.CONTENT_ELECTIVE_SELECT)
|
||||
async dropCourse(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
const input = dropCourseSchema.parse(body);
|
||||
await this.service.dropCourse(input.studentId, input.courseId);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
}
|
||||
42
services/content/src/electives/electives.dto.ts
Normal file
42
services/content/src/electives/electives.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createElectiveCourseSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
subjectId: z.string().min(1).max(32),
|
||||
teacherId: z.string().min(1).max(32),
|
||||
capacity: z.number().int().min(1).max(500).optional().default(30),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const listAvailableElectiveCoursesSchema = z.object({
|
||||
subjectId: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const listElectiveSelectionsSchema = z.object({
|
||||
studentId: z.string().min(1).max(32),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
|
||||
export const selectCourseSchema = z.object({
|
||||
studentId: z.string().min(1).max(32),
|
||||
courseId: z.string().min(1).max(32),
|
||||
});
|
||||
|
||||
export const dropCourseSchema = z.object({
|
||||
studentId: z.string().min(1).max(32),
|
||||
courseId: z.string().min(1).max(32),
|
||||
});
|
||||
|
||||
export type CreateElectiveCourseDto = z.infer<
|
||||
typeof createElectiveCourseSchema
|
||||
>;
|
||||
export type ListAvailableElectiveCoursesDto = z.infer<
|
||||
typeof listAvailableElectiveCoursesSchema
|
||||
>;
|
||||
export type ListElectiveSelectionsDto = z.infer<
|
||||
typeof listElectiveSelectionsSchema
|
||||
>;
|
||||
export type SelectCourseDto = z.infer<typeof selectCourseSchema>;
|
||||
export type DropCourseDto = z.infer<typeof dropCourseSchema>;
|
||||
10
services/content/src/electives/electives.module.ts
Normal file
10
services/content/src/electives/electives.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ElectivesController } from "./electives.controller.js";
|
||||
import { ElectivesService } from "./electives.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [ElectivesController],
|
||||
providers: [ElectivesService],
|
||||
exports: [ElectivesService],
|
||||
})
|
||||
export class ElectivesModule {}
|
||||
118
services/content/src/electives/electives.repository.ts
Normal file
118
services/content/src/electives/electives.repository.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
electiveCourses,
|
||||
electiveSelections,
|
||||
type ElectiveCourse,
|
||||
type NewElectiveCourse,
|
||||
type ElectiveSelection,
|
||||
type NewElectiveSelection,
|
||||
} from "./electives.schema.js";
|
||||
|
||||
export class ElectivesRepository {
|
||||
async findCourseById(id: string): Promise<ElectiveCourse | undefined> {
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findCourses(query?: {
|
||||
subjectId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<ElectiveCourse[]> {
|
||||
const db = getDb();
|
||||
let q = db.select().from(electiveCourses).$dynamic();
|
||||
if (query?.subjectId) {
|
||||
q = q.where(eq(electiveCourses.subjectId, query.subjectId));
|
||||
}
|
||||
const pageSize = query?.pageSize ?? 20;
|
||||
const page = query?.page ?? 1;
|
||||
return q.limit(pageSize).offset((page - 1) * pageSize);
|
||||
}
|
||||
|
||||
async createCourse(data: NewElectiveCourse): Promise<void> {
|
||||
await getDb().insert(electiveCourses).values(data);
|
||||
}
|
||||
|
||||
async updateCourse(
|
||||
id: string,
|
||||
data: Partial<NewElectiveCourse>,
|
||||
): Promise<void> {
|
||||
await getDb()
|
||||
.update(electiveCourses)
|
||||
.set(data)
|
||||
.where(eq(electiveCourses.id, id));
|
||||
}
|
||||
|
||||
async deleteCourse(id: string): Promise<void> {
|
||||
await getDb().delete(electiveCourses).where(eq(electiveCourses.id, id));
|
||||
}
|
||||
|
||||
async findSelectionsByStudent(
|
||||
studentId: string,
|
||||
status?: string,
|
||||
): Promise<ElectiveSelection[]> {
|
||||
const db = getDb();
|
||||
let q = db
|
||||
.select()
|
||||
.from(electiveSelections)
|
||||
.where(eq(electiveSelections.studentId, studentId))
|
||||
.$dynamic();
|
||||
if (status) {
|
||||
q = q.where(eq(electiveSelections.status, status));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
async findActiveSelection(
|
||||
studentId: string,
|
||||
courseId: string,
|
||||
): Promise<ElectiveSelection | undefined> {
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(electiveSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(electiveSelections.studentId, studentId),
|
||||
eq(electiveSelections.courseId, courseId),
|
||||
eq(electiveSelections.status, "selected"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async createSelection(data: NewElectiveSelection): Promise<void> {
|
||||
await getDb().insert(electiveSelections).values(data);
|
||||
}
|
||||
|
||||
async updateSelection(
|
||||
id: string,
|
||||
data: Partial<NewElectiveSelection>,
|
||||
): Promise<void> {
|
||||
await getDb()
|
||||
.update(electiveSelections)
|
||||
.set(data)
|
||||
.where(eq(electiveSelections.id, id));
|
||||
}
|
||||
|
||||
async countEnrolled(courseId: string): Promise<number> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(electiveSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(electiveSelections.courseId, courseId),
|
||||
eq(electiveSelections.status, "selected"),
|
||||
),
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const electivesRepository = new ElectivesRepository();
|
||||
57
services/content/src/electives/electives.schema.ts
Normal file
57
services/content/src/electives/electives.schema.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
timestamp,
|
||||
text,
|
||||
int,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
// 选修课目录:教师/管理员发布的可选修课程
|
||||
export const electiveCourses = mysqlTable(
|
||||
"content_elective_courses",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
subjectId: varchar("subject_id", { length: 32 }).notNull(),
|
||||
teacherId: varchar("teacher_id", { length: 32 }).notNull(),
|
||||
capacity: int("capacity").notNull().default(30),
|
||||
enrolledCount: int("enrolled_count").notNull().default(0),
|
||||
status: varchar("status", { length: 32 }).notNull().default("open"),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
subjectIdx: index("idx_elective_subject").on(table.subjectId),
|
||||
teacherIdx: index("idx_elective_teacher").on(table.teacherId),
|
||||
statusIdx: index("idx_elective_status").on(table.status),
|
||||
}),
|
||||
);
|
||||
|
||||
// 学生选课记录
|
||||
export const electiveSelections = mysqlTable(
|
||||
"content_elective_selections",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
studentId: varchar("student_id", { length: 32 }).notNull(),
|
||||
courseId: varchar("course_id", { length: 32 }).notNull(),
|
||||
status: varchar("status", { length: 32 }).notNull().default("selected"),
|
||||
selectedAt: timestamp("selected_at").notNull().defaultNow(),
|
||||
droppedAt: timestamp("dropped_at"),
|
||||
},
|
||||
(table) => ({
|
||||
studentIdx: index("idx_selection_student").on(table.studentId),
|
||||
courseIdx: index("idx_selection_course").on(table.courseId),
|
||||
studentCourseIdx: index("idx_selection_student_course").on(
|
||||
table.studentId,
|
||||
table.courseId,
|
||||
),
|
||||
statusIdx: index("idx_selection_status").on(table.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export type ElectiveCourse = typeof electiveCourses.$inferSelect;
|
||||
export type NewElectiveCourse = typeof electiveCourses.$inferInsert;
|
||||
export type ElectiveSelection = typeof electiveSelections.$inferSelect;
|
||||
export type NewElectiveSelection = typeof electiveSelections.$inferInsert;
|
||||
134
services/content/src/electives/electives.service.ts
Normal file
134
services/content/src/electives/electives.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { electivesRepository } from "./electives.repository.js";
|
||||
import type {
|
||||
ElectiveCourse,
|
||||
ElectiveSelection,
|
||||
NewElectiveCourse,
|
||||
NewElectiveSelection,
|
||||
} from "./electives.schema.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ConflictError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateElectiveCourseInput {
|
||||
title: string;
|
||||
subjectId: string;
|
||||
teacherId: string;
|
||||
capacity?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ListAvailableCoursesInput {
|
||||
subjectId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ElectivesService {
|
||||
async createCourse(
|
||||
input: CreateElectiveCourseInput,
|
||||
): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const record: NewElectiveCourse = {
|
||||
id,
|
||||
title: input.title,
|
||||
subjectId: input.subjectId,
|
||||
teacherId: input.teacherId,
|
||||
capacity: input.capacity ?? 30,
|
||||
enrolledCount: 0,
|
||||
status: "open",
|
||||
description: input.description ?? null,
|
||||
};
|
||||
await electivesRepository.createCourse(record);
|
||||
return { id };
|
||||
}
|
||||
|
||||
async listAvailableCourses(
|
||||
query: ListAvailableCoursesInput,
|
||||
): Promise<ElectiveCourse[]> {
|
||||
return electivesRepository.findCourses(query);
|
||||
}
|
||||
|
||||
async getCourseById(id: string): Promise<ElectiveCourse> {
|
||||
const course = await electivesRepository.findCourseById(id);
|
||||
if (!course) {
|
||||
throw new NotFoundError("ElectiveCourse", id);
|
||||
}
|
||||
return course;
|
||||
}
|
||||
|
||||
async listSelectionsByStudent(
|
||||
studentId: string,
|
||||
status?: string,
|
||||
): Promise<ElectiveSelection[]> {
|
||||
return electivesRepository.findSelectionsByStudent(studentId, status);
|
||||
}
|
||||
|
||||
async selectCourse(
|
||||
studentId: string,
|
||||
courseId: string,
|
||||
): Promise<{ id: string }> {
|
||||
const course = await this.getCourseById(courseId);
|
||||
if (course.status !== "open") {
|
||||
throw new ValidationError(
|
||||
`Course is not open for selection: ${courseId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await electivesRepository.findActiveSelection(
|
||||
studentId,
|
||||
courseId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictError(
|
||||
`Student ${studentId} has already selected course ${courseId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const enrolled = await electivesRepository.countEnrolled(courseId);
|
||||
if (enrolled >= course.capacity) {
|
||||
throw new ValidationError(
|
||||
`Course ${courseId} is full (capacity=${course.capacity})`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
const record: NewElectiveSelection = {
|
||||
id,
|
||||
studentId,
|
||||
courseId,
|
||||
status: "selected",
|
||||
};
|
||||
await electivesRepository.createSelection(record);
|
||||
|
||||
await electivesRepository.updateCourse(courseId, {
|
||||
enrolledCount: enrolled + 1,
|
||||
});
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async dropCourse(studentId: string, courseId: string): Promise<void> {
|
||||
const existing = await electivesRepository.findActiveSelection(
|
||||
studentId,
|
||||
courseId,
|
||||
);
|
||||
if (!existing) {
|
||||
throw new NotFoundError("ElectiveSelection", `${studentId}/${courseId}`);
|
||||
}
|
||||
|
||||
await electivesRepository.updateSelection(existing.id, {
|
||||
status: "dropped",
|
||||
droppedAt: new Date(),
|
||||
});
|
||||
|
||||
const enrolled = await electivesRepository.countEnrolled(courseId);
|
||||
await electivesRepository.updateCourse(courseId, {
|
||||
enrolledCount: Math.max(0, enrolled - 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
49
services/content/src/grpc/course-plan.grpc.controller.ts
Normal file
49
services/content/src/grpc/course-plan.grpc.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { CoursePlansService } from "../course-plans/course-plans.service.js";
|
||||
import type { CoursePlan } from "../course-plans/course-plans.schema.js";
|
||||
import type {
|
||||
GrpcCoursePlan,
|
||||
ListCoursePlansByStudentRequest,
|
||||
ListCoursePlansResponse,
|
||||
GetCoursePlanRequest,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcCoursePlan(cp: CoursePlan): GrpcCoursePlan {
|
||||
return {
|
||||
id: cp.id,
|
||||
student_id: cp.studentId,
|
||||
class_id: cp.classId,
|
||||
subject_id: cp.subjectId,
|
||||
title: cp.title,
|
||||
plan_type: cp.planType,
|
||||
content: cp.content,
|
||||
status: cp.status,
|
||||
metadata: cp.metadata ?? null,
|
||||
created_at: cp.createdAt.getTime(),
|
||||
updated_at: cp.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class CoursePlanGrpcController {
|
||||
constructor(private readonly service: CoursePlansService) {}
|
||||
|
||||
@GrpcMethod("CoursePlanService", "ListCoursePlansByStudent")
|
||||
async listByStudent(
|
||||
data: ListCoursePlansByStudentRequest,
|
||||
): Promise<ListCoursePlansResponse> {
|
||||
const plans = await this.service.listByStudent({
|
||||
studentId: data.student_id,
|
||||
classId: data.class_id,
|
||||
planType: data.plan_type,
|
||||
});
|
||||
return { course_plans: plans.map(toGrpcCoursePlan) };
|
||||
}
|
||||
|
||||
@GrpcMethod("CoursePlanService", "GetCoursePlan")
|
||||
async getCoursePlan(data: GetCoursePlanRequest): Promise<GrpcCoursePlan> {
|
||||
const plan = await this.service.getById(data.id);
|
||||
return toGrpcCoursePlan(plan);
|
||||
}
|
||||
}
|
||||
102
services/content/src/grpc/elective.grpc.controller.ts
Normal file
102
services/content/src/grpc/elective.grpc.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { ElectivesService } from "../electives/electives.service.js";
|
||||
import type {
|
||||
ElectiveCourse,
|
||||
ElectiveSelection,
|
||||
} from "../electives/electives.schema.js";
|
||||
import type {
|
||||
GrpcElectiveCourse,
|
||||
GrpcElectiveSelection,
|
||||
ListAvailableElectiveCoursesRequest,
|
||||
ListElectiveCoursesResponse,
|
||||
ListElectiveSelectionsByStudentRequest,
|
||||
ListElectiveSelectionsResponse,
|
||||
SelectCourseRequest,
|
||||
DropCourseRequest,
|
||||
Empty,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcCourse(c: ElectiveCourse): GrpcElectiveCourse {
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
subject_id: c.subjectId,
|
||||
teacher_id: c.teacherId,
|
||||
capacity: c.capacity,
|
||||
enrolled_count: c.enrolledCount,
|
||||
status: c.status,
|
||||
description: c.description ?? "",
|
||||
created_at: c.createdAt.getTime(),
|
||||
updated_at: c.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function toGrpcSelection(s: ElectiveSelection): GrpcElectiveSelection {
|
||||
return {
|
||||
id: s.id,
|
||||
student_id: s.studentId,
|
||||
course_id: s.courseId,
|
||||
status: s.status,
|
||||
selected_at: s.selectedAt.getTime(),
|
||||
dropped_at: s.droppedAt ? s.droppedAt.getTime() : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class ElectiveGrpcController {
|
||||
constructor(private readonly service: ElectivesService) {}
|
||||
|
||||
@GrpcMethod("ElectiveService", "ListAvailableElectiveCourses")
|
||||
async listAvailableCourses(
|
||||
data: ListAvailableElectiveCoursesRequest,
|
||||
): Promise<ListElectiveCoursesResponse> {
|
||||
const pageSize = data.page_size ?? 20;
|
||||
const page = data.page_token ? Number(data.page_token) : 1;
|
||||
const courses = await this.service.listAvailableCourses({
|
||||
subjectId: data.subject_id,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return {
|
||||
courses: courses.map(toGrpcCourse),
|
||||
next_page_token: courses.length === pageSize ? String(page + 1) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("ElectiveService", "ListElectiveSelectionsByStudent")
|
||||
async listSelectionsByStudent(
|
||||
data: ListElectiveSelectionsByStudentRequest,
|
||||
): Promise<ListElectiveSelectionsResponse> {
|
||||
const selections = await this.service.listSelectionsByStudent(
|
||||
data.student_id,
|
||||
data.status,
|
||||
);
|
||||
return { selections: selections.map(toGrpcSelection) };
|
||||
}
|
||||
|
||||
@GrpcMethod("ElectiveService", "SelectCourse")
|
||||
async selectCourse(
|
||||
data: SelectCourseRequest,
|
||||
): Promise<GrpcElectiveSelection> {
|
||||
const { id } = await this.service.selectCourse(
|
||||
data.student_id,
|
||||
data.course_id,
|
||||
);
|
||||
const selections = await this.service.listSelectionsByStudent(
|
||||
data.student_id,
|
||||
"selected",
|
||||
);
|
||||
const sel = selections.find((s) => s.id === id);
|
||||
if (!sel) {
|
||||
throw new Error(`Selection ${id} not found after select`);
|
||||
}
|
||||
return toGrpcSelection(sel);
|
||||
}
|
||||
|
||||
@GrpcMethod("ElectiveService", "DropCourse")
|
||||
async dropCourse(data: DropCourseRequest): Promise<Empty> {
|
||||
await this.service.dropCourse(data.student_id, data.course_id);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -233,3 +233,124 @@ export interface SearchQuestionsResponse {
|
||||
total: number;
|
||||
next_page_token: string;
|
||||
}
|
||||
|
||||
// KnowledgeGraphService: GetKnowledgePath (按班级查询)
|
||||
export interface GetKnowledgePathRequest {
|
||||
class_id: string;
|
||||
subject_id?: string;
|
||||
}
|
||||
|
||||
// ElectiveService types
|
||||
export interface GrpcElectiveCourse {
|
||||
id: string;
|
||||
title: string;
|
||||
subject_id: string;
|
||||
teacher_id: string;
|
||||
capacity: number;
|
||||
enrolled_count: number;
|
||||
status: string;
|
||||
description: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface GrpcElectiveSelection {
|
||||
id: string;
|
||||
student_id: string;
|
||||
course_id: string;
|
||||
status: string;
|
||||
selected_at: number;
|
||||
dropped_at: number;
|
||||
}
|
||||
|
||||
export interface ListAvailableElectiveCoursesRequest {
|
||||
subject_id?: string;
|
||||
page_size?: number;
|
||||
page_token?: string;
|
||||
}
|
||||
|
||||
export interface ListElectiveCoursesResponse {
|
||||
courses: GrpcElectiveCourse[];
|
||||
next_page_token: string;
|
||||
}
|
||||
|
||||
export interface ListElectiveSelectionsByStudentRequest {
|
||||
student_id: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ListElectiveSelectionsResponse {
|
||||
selections: GrpcElectiveSelection[];
|
||||
}
|
||||
|
||||
export interface SelectCourseRequest {
|
||||
student_id: string;
|
||||
course_id: string;
|
||||
}
|
||||
|
||||
export interface DropCourseRequest {
|
||||
student_id: string;
|
||||
course_id: string;
|
||||
}
|
||||
|
||||
// LessonPlanService types
|
||||
export interface GrpcLessonPlan {
|
||||
id: string;
|
||||
teacher_id: string;
|
||||
class_id: string;
|
||||
subject_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ListLessonPlansByTeacherRequest {
|
||||
teacher_id: string;
|
||||
class_id?: string;
|
||||
subject_id?: string;
|
||||
}
|
||||
|
||||
export interface ListLessonPlansByStudentRequest {
|
||||
student_id: string;
|
||||
class_id?: string;
|
||||
}
|
||||
|
||||
export interface ListLessonPlansResponse {
|
||||
lesson_plans: GrpcLessonPlan[];
|
||||
}
|
||||
|
||||
export interface GetLessonPlanRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// CoursePlanService types
|
||||
export interface GrpcCoursePlan {
|
||||
id: string;
|
||||
student_id: string;
|
||||
class_id: string;
|
||||
subject_id: string;
|
||||
title: string;
|
||||
plan_type: string;
|
||||
content: string;
|
||||
status: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ListCoursePlansByStudentRequest {
|
||||
student_id: string;
|
||||
class_id?: string;
|
||||
plan_type?: string;
|
||||
}
|
||||
|
||||
export interface ListCoursePlansResponse {
|
||||
course_plans: GrpcCoursePlan[];
|
||||
}
|
||||
|
||||
export interface GetCoursePlanRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@ import { TextbooksModule } from "../textbooks/textbooks.module.js";
|
||||
import { ChaptersModule } from "../chapters/chapters.module.js";
|
||||
import { KnowledgePointsModule } from "../knowledge-points/knowledge-points.module.js";
|
||||
import { QuestionsModule } from "../questions/questions.module.js";
|
||||
import { ElectivesModule } from "../electives/electives.module.js";
|
||||
import { LessonPlansModule } from "../lesson-plans/lesson-plans.module.js";
|
||||
import { CoursePlansModule } from "../course-plans/course-plans.module.js";
|
||||
import { TextbookGrpcController } from "./textbook.grpc.controller.js";
|
||||
import { ChapterGrpcController } from "./chapter.grpc.controller.js";
|
||||
import { KnowledgeGraphGrpcController } from "./knowledge-graph.grpc.controller.js";
|
||||
import { QuestionGrpcController } from "./question.grpc.controller.js";
|
||||
import { ElectiveGrpcController } from "./elective.grpc.controller.js";
|
||||
import { LessonPlanGrpcController } from "./lesson-plan.grpc.controller.js";
|
||||
import { CoursePlanGrpcController } from "./course-plan.grpc.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -14,12 +20,18 @@ import { QuestionGrpcController } from "./question.grpc.controller.js";
|
||||
ChaptersModule,
|
||||
KnowledgePointsModule,
|
||||
QuestionsModule,
|
||||
ElectivesModule,
|
||||
LessonPlansModule,
|
||||
CoursePlansModule,
|
||||
],
|
||||
controllers: [
|
||||
TextbookGrpcController,
|
||||
ChapterGrpcController,
|
||||
KnowledgeGraphGrpcController,
|
||||
QuestionGrpcController,
|
||||
ElectiveGrpcController,
|
||||
LessonPlanGrpcController,
|
||||
CoursePlanGrpcController,
|
||||
],
|
||||
})
|
||||
export class GrpcModule {}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GetPrerequisitesRequest,
|
||||
KnowledgePointsResponse,
|
||||
GetLearningPathRequest,
|
||||
GetKnowledgePathRequest,
|
||||
LearningPath,
|
||||
AddPrerequisiteRequest,
|
||||
RemovePrerequisiteRequest,
|
||||
@@ -34,11 +35,9 @@ export class KnowledgeGraphGrpcController {
|
||||
async getPrerequisites(
|
||||
data: GetPrerequisitesRequest,
|
||||
): Promise<KnowledgePointsResponse> {
|
||||
// 直接使用 service.getPrerequisites(读 Neo4j 派生数据)
|
||||
const prereqs = await this.service.getPrerequisites(
|
||||
data.knowledge_point_id,
|
||||
);
|
||||
// PrerequisiteNode 只含 id + title,需要查 MySQL 补全信息
|
||||
const points: GrpcKnowledgePoint[] = [];
|
||||
for (const p of prereqs) {
|
||||
try {
|
||||
@@ -52,10 +51,28 @@ export class KnowledgeGraphGrpcController {
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "GetLearningPath")
|
||||
async getLearningPath(_data: GetLearningPathRequest): Promise<LearningPath> {
|
||||
// P4 阶段未实现学习路径推荐算法,返回空路径
|
||||
// P5+ 引入 AI 服务后实现
|
||||
return { points: [], recommended_order: [] };
|
||||
async getLearningPath(data: GetLearningPathRequest): Promise<LearningPath> {
|
||||
// 按学科查询学习路径,基于知识点难度升序推荐
|
||||
const { points, recommendedOrder } = await this.service.getLearningPath(
|
||||
data.subject_id,
|
||||
);
|
||||
return {
|
||||
points: points.map(toGrpcKp),
|
||||
recommended_order: recommendedOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "GetKnowledgePath")
|
||||
async getKnowledgePath(data: GetKnowledgePathRequest): Promise<LearningPath> {
|
||||
// 按班级查询学习路径(班级维度不直接影响知识点排序,
|
||||
// 此处按 subject_id 过滤,后续可接入班级学情数据个性化推荐)
|
||||
const { points, recommendedOrder } = await this.service.getLearningPath(
|
||||
data.subject_id,
|
||||
);
|
||||
return {
|
||||
points: points.map(toGrpcKp),
|
||||
recommended_order: recommendedOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "AddPrerequisite")
|
||||
|
||||
60
services/content/src/grpc/lesson-plan.grpc.controller.ts
Normal file
60
services/content/src/grpc/lesson-plan.grpc.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { LessonPlansService } from "../lesson-plans/lesson-plans.service.js";
|
||||
import type { LessonPlan } from "../lesson-plans/lesson-plans.schema.js";
|
||||
import type {
|
||||
GrpcLessonPlan,
|
||||
ListLessonPlansByTeacherRequest,
|
||||
ListLessonPlansByStudentRequest,
|
||||
ListLessonPlansResponse,
|
||||
GetLessonPlanRequest,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcLessonPlan(lp: LessonPlan): GrpcLessonPlan {
|
||||
return {
|
||||
id: lp.id,
|
||||
teacher_id: lp.teacherId,
|
||||
class_id: lp.classId,
|
||||
subject_id: lp.subjectId,
|
||||
title: lp.title,
|
||||
content: lp.content,
|
||||
status: lp.status,
|
||||
metadata: lp.metadata ?? null,
|
||||
created_at: lp.createdAt.getTime(),
|
||||
updated_at: lp.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class LessonPlanGrpcController {
|
||||
constructor(private readonly service: LessonPlansService) {}
|
||||
|
||||
@GrpcMethod("LessonPlanService", "ListLessonPlansByTeacher")
|
||||
async listByTeacher(
|
||||
data: ListLessonPlansByTeacherRequest,
|
||||
): Promise<ListLessonPlansResponse> {
|
||||
const plans = await this.service.listByTeacher({
|
||||
teacherId: data.teacher_id,
|
||||
classId: data.class_id,
|
||||
subjectId: data.subject_id,
|
||||
});
|
||||
return { lesson_plans: plans.map(toGrpcLessonPlan) };
|
||||
}
|
||||
|
||||
@GrpcMethod("LessonPlanService", "ListLessonPlansByStudent")
|
||||
async listByStudent(
|
||||
data: ListLessonPlansByStudentRequest,
|
||||
): Promise<ListLessonPlansResponse> {
|
||||
const plans = await this.service.listByStudent({
|
||||
studentId: data.student_id,
|
||||
classId: data.class_id,
|
||||
});
|
||||
return { lesson_plans: plans.map(toGrpcLessonPlan) };
|
||||
}
|
||||
|
||||
@GrpcMethod("LessonPlanService", "GetLessonPlan")
|
||||
async getLessonPlan(data: GetLessonPlanRequest): Promise<GrpcLessonPlan> {
|
||||
const plan = await this.service.getById(data.id);
|
||||
return toGrpcLessonPlan(plan);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,23 @@ export class KnowledgePointsService {
|
||||
return knowledgePointsRepository.findByChapterId(chapterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学习路径推荐:基于知识点难度升序返回学习路径。
|
||||
* 当前实现:查询全部知识点,按 difficulty 升序排序作为推荐顺序。
|
||||
* 后续可接入 AI 服务做个性化推荐,并按 subjectId 过滤。
|
||||
*/
|
||||
async getLearningPath(
|
||||
_subjectId?: string,
|
||||
): Promise<{ points: KnowledgePoint[]; recommendedOrder: string[] }> {
|
||||
const allKps = await knowledgePointsRepository.findAll();
|
||||
// 按难度升序排序,作为推荐学习顺序
|
||||
const sorted = [...allKps].sort((a, b) => a.difficulty - b.difficulty);
|
||||
return {
|
||||
points: sorted,
|
||||
recommendedOrder: sorted.map((kp) => kp.id),
|
||||
};
|
||||
}
|
||||
|
||||
async updateKnowledgePoint(
|
||||
id: string,
|
||||
data: UpdateKnowledgePointInput,
|
||||
|
||||
85
services/content/src/lesson-plans/lesson-plans.controller.ts
Normal file
85
services/content/src/lesson-plans/lesson-plans.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { LessonPlansService } from "./lesson-plans.service.js";
|
||||
import type { LessonPlan } from "./lesson-plans.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
createLessonPlanSchema,
|
||||
updateLessonPlanSchema,
|
||||
listLessonPlansByTeacherSchema,
|
||||
listLessonPlansByStudentSchema,
|
||||
} from "./lesson-plans.dto.js";
|
||||
|
||||
@Controller("lesson-plans")
|
||||
export class LessonPlansController {
|
||||
constructor(private readonly service: LessonPlansService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_CREATE)
|
||||
async create(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const input = createLessonPlanSchema.parse(body);
|
||||
const result = await this.service.create(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_READ)
|
||||
async list(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: LessonPlan[] }> {
|
||||
const q = query as Record<string, string>;
|
||||
if (q.teacherId) {
|
||||
const input = listLessonPlansByTeacherSchema.parse(query);
|
||||
const data = await this.service.listByTeacher(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
if (q.studentId) {
|
||||
const input = listLessonPlansByStudentSchema.parse(query);
|
||||
const data = await this.service.listByStudent(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_READ)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: LessonPlan }> {
|
||||
const data = await this.service.getById(id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
const input = updateLessonPlanSchema.parse(body);
|
||||
await this.service.update(id, input);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_DELETE)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.service.delete(id);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
}
|
||||
37
services/content/src/lesson-plans/lesson-plans.dto.ts
Normal file
37
services/content/src/lesson-plans/lesson-plans.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createLessonPlanSchema = z.object({
|
||||
teacherId: z.string().min(1).max(32),
|
||||
classId: z.string().min(1).max(32),
|
||||
subjectId: z.string().min(1).max(32),
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.string().min(1),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const updateLessonPlanSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const listLessonPlansByTeacherSchema = z.object({
|
||||
teacherId: z.string().min(1).max(32),
|
||||
classId: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const listLessonPlansByStudentSchema = z.object({
|
||||
studentId: z.string().min(1).max(32),
|
||||
classId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateLessonPlanDto = z.infer<typeof createLessonPlanSchema>;
|
||||
export type UpdateLessonPlanDto = z.infer<typeof updateLessonPlanSchema>;
|
||||
export type ListLessonPlansByTeacherDto = z.infer<
|
||||
typeof listLessonPlansByTeacherSchema
|
||||
>;
|
||||
export type ListLessonPlansByStudentDto = z.infer<
|
||||
typeof listLessonPlansByStudentSchema
|
||||
>;
|
||||
10
services/content/src/lesson-plans/lesson-plans.module.ts
Normal file
10
services/content/src/lesson-plans/lesson-plans.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { LessonPlansController } from "./lesson-plans.controller.js";
|
||||
import { LessonPlansService } from "./lesson-plans.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [LessonPlansController],
|
||||
providers: [LessonPlansService],
|
||||
exports: [LessonPlansService],
|
||||
})
|
||||
export class LessonPlansModule {}
|
||||
69
services/content/src/lesson-plans/lesson-plans.repository.ts
Normal file
69
services/content/src/lesson-plans/lesson-plans.repository.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
lessonPlans,
|
||||
type LessonPlan,
|
||||
type NewLessonPlan,
|
||||
} from "./lesson-plans.schema.js";
|
||||
|
||||
export class LessonPlansRepository {
|
||||
async findById(id: string): Promise<LessonPlan | undefined> {
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(eq(lessonPlans.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findByTeacher(query: {
|
||||
teacherId: string;
|
||||
classId?: string;
|
||||
subjectId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
const db = getDb();
|
||||
const conditions = [eq(lessonPlans.teacherId, query.teacherId)];
|
||||
if (query.classId) {
|
||||
conditions.push(eq(lessonPlans.classId, query.classId));
|
||||
}
|
||||
if (query.subjectId) {
|
||||
conditions.push(eq(lessonPlans.subjectId, query.subjectId));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
async findByStudent(query: {
|
||||
studentId: string;
|
||||
classId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
// 学生视角:按班级查询教师发布的备课计划
|
||||
// studentId 不直接关联备课计划(备课计划是教师创建的),
|
||||
// 通过 classId 过滤学生所在班级的备课计划
|
||||
const db = getDb();
|
||||
const conditions = [eq(lessonPlans.status, "published")];
|
||||
if (query.classId) {
|
||||
conditions.push(eq(lessonPlans.classId, query.classId));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
async create(data: NewLessonPlan): Promise<void> {
|
||||
await getDb().insert(lessonPlans).values(data);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewLessonPlan>): Promise<void> {
|
||||
await getDb().update(lessonPlans).set(data).where(eq(lessonPlans.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await getDb().delete(lessonPlans).where(eq(lessonPlans.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export const lessonPlansRepository = new LessonPlansRepository();
|
||||
34
services/content/src/lesson-plans/lesson-plans.schema.ts
Normal file
34
services/content/src/lesson-plans/lesson-plans.schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
timestamp,
|
||||
text,
|
||||
json,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
// 备课计划:教师为班级/学科创建的教学计划
|
||||
export const lessonPlans = mysqlTable(
|
||||
"content_lesson_plans",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
teacherId: varchar("teacher_id", { length: 32 }).notNull(),
|
||||
classId: varchar("class_id", { length: 32 }).notNull(),
|
||||
subjectId: varchar("subject_id", { length: 32 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
status: varchar("status", { length: 32 }).notNull().default("draft"),
|
||||
metadata: json("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
teacherIdx: index("idx_lesson_plan_teacher").on(table.teacherId),
|
||||
classIdx: index("idx_lesson_plan_class").on(table.classId),
|
||||
subjectIdx: index("idx_lesson_plan_subject").on(table.subjectId),
|
||||
statusIdx: index("idx_lesson_plan_status").on(table.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export type LessonPlan = typeof lessonPlans.$inferSelect;
|
||||
export type NewLessonPlan = typeof lessonPlans.$inferInsert;
|
||||
73
services/content/src/lesson-plans/lesson-plans.service.ts
Normal file
73
services/content/src/lesson-plans/lesson-plans.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { lessonPlansRepository } from "./lesson-plans.repository.js";
|
||||
import type { LessonPlan, NewLessonPlan } from "./lesson-plans.schema.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateLessonPlanInput {
|
||||
teacherId: string;
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateLessonPlanInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LessonPlansService {
|
||||
async create(input: CreateLessonPlanInput): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const record: NewLessonPlan = {
|
||||
id,
|
||||
teacherId: input.teacherId,
|
||||
classId: input.classId,
|
||||
subjectId: input.subjectId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
status: "draft",
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
await lessonPlansRepository.create(record);
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<LessonPlan> {
|
||||
const plan = await lessonPlansRepository.findById(id);
|
||||
if (!plan) {
|
||||
throw new NotFoundError("LessonPlan", id);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
async listByTeacher(query: {
|
||||
teacherId: string;
|
||||
classId?: string;
|
||||
subjectId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
return lessonPlansRepository.findByTeacher(query);
|
||||
}
|
||||
|
||||
async listByStudent(query: {
|
||||
studentId: string;
|
||||
classId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
return lessonPlansRepository.findByStudent(query);
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateLessonPlanInput): Promise<void> {
|
||||
await this.getById(id);
|
||||
await lessonPlansRepository.update(id, data);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await lessonPlansRepository.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,18 @@ export const Permissions = {
|
||||
CONTENT_KNOWLEDGE_POINT_READ: "CONTENT_KNOWLEDGE_POINT_READ" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_UPDATE: "CONTENT_KNOWLEDGE_POINT_UPDATE" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_DELETE: "CONTENT_KNOWLEDGE_POINT_DELETE" as const,
|
||||
// v2 新增:选修课 / 备课计划 / 课程计划
|
||||
CONTENT_ELECTIVE_CREATE: "CONTENT_ELECTIVE_CREATE" as const,
|
||||
CONTENT_ELECTIVE_READ: "CONTENT_ELECTIVE_READ" as const,
|
||||
CONTENT_ELECTIVE_SELECT: "CONTENT_ELECTIVE_SELECT" as const,
|
||||
CONTENT_LESSON_PLAN_CREATE: "CONTENT_LESSON_PLAN_CREATE" as const,
|
||||
CONTENT_LESSON_PLAN_READ: "CONTENT_LESSON_PLAN_READ" as const,
|
||||
CONTENT_LESSON_PLAN_UPDATE: "CONTENT_LESSON_PLAN_UPDATE" as const,
|
||||
CONTENT_LESSON_PLAN_DELETE: "CONTENT_LESSON_PLAN_DELETE" as const,
|
||||
CONTENT_COURSE_PLAN_CREATE: "CONTENT_COURSE_PLAN_CREATE" as const,
|
||||
CONTENT_COURSE_PLAN_READ: "CONTENT_COURSE_PLAN_READ" as const,
|
||||
CONTENT_COURSE_PLAN_UPDATE: "CONTENT_COURSE_PLAN_UPDATE" as const,
|
||||
CONTENT_COURSE_PLAN_DELETE: "CONTENT_COURSE_PLAN_DELETE" as const,
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
||||
@@ -51,6 +63,17 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_DELETE,
|
||||
Permissions.CONTENT_ELECTIVE_CREATE,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
Permissions.CONTENT_ELECTIVE_SELECT,
|
||||
Permissions.CONTENT_LESSON_PLAN_CREATE,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_UPDATE,
|
||||
Permissions.CONTENT_LESSON_PLAN_DELETE,
|
||||
Permissions.CONTENT_COURSE_PLAN_CREATE,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
Permissions.CONTENT_COURSE_PLAN_UPDATE,
|
||||
Permissions.CONTENT_COURSE_PLAN_DELETE,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
@@ -63,12 +86,30 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
|
||||
Permissions.CONTENT_ELECTIVE_CREATE,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_CREATE,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_UPDATE,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
],
|
||||
student: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
Permissions.CONTENT_CHAPTER_READ,
|
||||
Permissions.CONTENT_QUESTION_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
Permissions.CONTENT_ELECTIVE_SELECT,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
],
|
||||
parent: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
Permissions.CONTENT_CHAPTER_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../../config/env.js';
|
||||
import { pino } from "pino";
|
||||
import { env } from "../../config/env.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
// 修复:pino 默认字段选项为 `base`,而非 `defaultFields`
|
||||
base: {
|
||||
service: 'content',
|
||||
version: '0.1.0',
|
||||
service: "content",
|
||||
version: "0.1.0",
|
||||
},
|
||||
transport:
|
||||
env.NODE_ENV === 'development'
|
||||
env.NODE_ENV === "development"
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true },
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user