diff --git a/infra/init-sql/02-all-services-schema.sql b/infra/init-sql/02-all-services-schema.sql index 65a998e..7c4c62c 100644 --- a/infra/init-sql/02-all-services-schema.sql +++ b/infra/init-sql/02-all-services-schema.sql @@ -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) diff --git a/packages/shared-proto/proto/content.proto b/packages/shared-proto/proto/content.proto index f5551f4..387e885 100644 --- a/packages/shared-proto/proto/content.proto +++ b/packages/shared-proto/proto/content.proto @@ -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; +} diff --git a/services/content/docs/nextstep-v2.md b/services/content/docs/nextstep-v2.md new file mode 100644 index 0000000..d4f91aa --- /dev/null +++ b/services/content/docs/nextstep-v2.md @@ -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 完成后通知更新状态。** diff --git a/services/content/docs/v2-migration.sql b/services/content/docs/v2-migration.sql new file mode 100644 index 0000000..973c3b1 --- /dev/null +++ b/services/content/docs/v2-migration.sql @@ -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; diff --git a/services/content/src/app.module.ts b/services/content/src/app.module.ts index a0c1af3..a2ee1ed 100644 --- a/services/content/src/app.module.ts +++ b/services/content/src/app.module.ts @@ -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, diff --git a/services/content/src/course-plans/course-plans.controller.ts b/services/content/src/course-plans/course-plans.controller.ts new file mode 100644 index 0000000..39025e2 --- /dev/null +++ b/services/content/src/course-plans/course-plans.controller.ts @@ -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 } }; + } +} diff --git a/services/content/src/course-plans/course-plans.dto.ts b/services/content/src/course-plans/course-plans.dto.ts new file mode 100644 index 0000000..f2f87b6 --- /dev/null +++ b/services/content/src/course-plans/course-plans.dto.ts @@ -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; +export type UpdateCoursePlanDto = z.infer; +export type ListCoursePlansByStudentDto = z.infer< + typeof listCoursePlansByStudentSchema +>; diff --git a/services/content/src/course-plans/course-plans.module.ts b/services/content/src/course-plans/course-plans.module.ts new file mode 100644 index 0000000..391ac94 --- /dev/null +++ b/services/content/src/course-plans/course-plans.module.ts @@ -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 {} diff --git a/services/content/src/course-plans/course-plans.repository.ts b/services/content/src/course-plans/course-plans.repository.ts new file mode 100644 index 0000000..00723cd --- /dev/null +++ b/services/content/src/course-plans/course-plans.repository.ts @@ -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 { + 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 { + 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 { + await getDb().insert(coursePlans).values(data); + } + + async update(id: string, data: Partial): Promise { + await getDb().update(coursePlans).set(data).where(eq(coursePlans.id, id)); + } + + async delete(id: string): Promise { + await getDb().delete(coursePlans).where(eq(coursePlans.id, id)); + } +} + +export const coursePlansRepository = new CoursePlansRepository(); diff --git a/services/content/src/course-plans/course-plans.schema.ts b/services/content/src/course-plans/course-plans.schema.ts new file mode 100644 index 0000000..d1ddc66 --- /dev/null +++ b/services/content/src/course-plans/course-plans.schema.ts @@ -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 | 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; diff --git a/services/content/src/course-plans/course-plans.service.ts b/services/content/src/course-plans/course-plans.service.ts new file mode 100644 index 0000000..477bb01 --- /dev/null +++ b/services/content/src/course-plans/course-plans.service.ts @@ -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 | null; +} + +export interface UpdateCoursePlanInput { + title?: string; + content?: string; + planType?: string; + status?: string; + metadata?: Record | 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 { + 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 { + return coursePlansRepository.findByStudent(query); + } + + async update(id: string, data: UpdateCoursePlanInput): Promise { + await this.getById(id); + await coursePlansRepository.update(id, data); + } + + async delete(id: string): Promise { + await this.getById(id); + await coursePlansRepository.delete(id); + } +} diff --git a/services/content/src/electives/electives.controller.ts b/services/content/src/electives/electives.controller.ts new file mode 100644 index 0000000..217ab42 --- /dev/null +++ b/services/content/src/electives/electives.controller.ts @@ -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 } }; + } +} diff --git a/services/content/src/electives/electives.dto.ts b/services/content/src/electives/electives.dto.ts new file mode 100644 index 0000000..7c6a4d6 --- /dev/null +++ b/services/content/src/electives/electives.dto.ts @@ -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; +export type DropCourseDto = z.infer; diff --git a/services/content/src/electives/electives.module.ts b/services/content/src/electives/electives.module.ts new file mode 100644 index 0000000..71ae6b3 --- /dev/null +++ b/services/content/src/electives/electives.module.ts @@ -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 {} diff --git a/services/content/src/electives/electives.repository.ts b/services/content/src/electives/electives.repository.ts new file mode 100644 index 0000000..ea13842 --- /dev/null +++ b/services/content/src/electives/electives.repository.ts @@ -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 { + 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 { + 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 { + await getDb().insert(electiveCourses).values(data); + } + + async updateCourse( + id: string, + data: Partial, + ): Promise { + await getDb() + .update(electiveCourses) + .set(data) + .where(eq(electiveCourses.id, id)); + } + + async deleteCourse(id: string): Promise { + await getDb().delete(electiveCourses).where(eq(electiveCourses.id, id)); + } + + async findSelectionsByStudent( + studentId: string, + status?: string, + ): Promise { + 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 { + 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 { + await getDb().insert(electiveSelections).values(data); + } + + async updateSelection( + id: string, + data: Partial, + ): Promise { + await getDb() + .update(electiveSelections) + .set(data) + .where(eq(electiveSelections.id, id)); + } + + async countEnrolled(courseId: string): Promise { + 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(); diff --git a/services/content/src/electives/electives.schema.ts b/services/content/src/electives/electives.schema.ts new file mode 100644 index 0000000..2450a76 --- /dev/null +++ b/services/content/src/electives/electives.schema.ts @@ -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; diff --git a/services/content/src/electives/electives.service.ts b/services/content/src/electives/electives.service.ts new file mode 100644 index 0000000..d2b485e --- /dev/null +++ b/services/content/src/electives/electives.service.ts @@ -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 { + return electivesRepository.findCourses(query); + } + + async getCourseById(id: string): Promise { + const course = await electivesRepository.findCourseById(id); + if (!course) { + throw new NotFoundError("ElectiveCourse", id); + } + return course; + } + + async listSelectionsByStudent( + studentId: string, + status?: string, + ): Promise { + 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 { + 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), + }); + } +} diff --git a/services/content/src/grpc/course-plan.grpc.controller.ts b/services/content/src/grpc/course-plan.grpc.controller.ts new file mode 100644 index 0000000..4479bc0 --- /dev/null +++ b/services/content/src/grpc/course-plan.grpc.controller.ts @@ -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 { + 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 { + const plan = await this.service.getById(data.id); + return toGrpcCoursePlan(plan); + } +} diff --git a/services/content/src/grpc/elective.grpc.controller.ts b/services/content/src/grpc/elective.grpc.controller.ts new file mode 100644 index 0000000..bb53ff3 --- /dev/null +++ b/services/content/src/grpc/elective.grpc.controller.ts @@ -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 { + 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 { + const selections = await this.service.listSelectionsByStudent( + data.student_id, + data.status, + ); + return { selections: selections.map(toGrpcSelection) }; + } + + @GrpcMethod("ElectiveService", "SelectCourse") + async selectCourse( + data: SelectCourseRequest, + ): Promise { + 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 { + await this.service.dropCourse(data.student_id, data.course_id); + return {}; + } +} diff --git a/services/content/src/grpc/grpc-types.ts b/services/content/src/grpc/grpc-types.ts index cbe3281..5cb291c 100644 --- a/services/content/src/grpc/grpc-types.ts +++ b/services/content/src/grpc/grpc-types.ts @@ -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 | 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 | 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; +} diff --git a/services/content/src/grpc/grpc.module.ts b/services/content/src/grpc/grpc.module.ts index 7c89d3b..e501db9 100644 --- a/services/content/src/grpc/grpc.module.ts +++ b/services/content/src/grpc/grpc.module.ts @@ -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 {} diff --git a/services/content/src/grpc/knowledge-graph.grpc.controller.ts b/services/content/src/grpc/knowledge-graph.grpc.controller.ts index 159d443..4bcea04 100644 --- a/services/content/src/grpc/knowledge-graph.grpc.controller.ts +++ b/services/content/src/grpc/knowledge-graph.grpc.controller.ts @@ -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 { - // 直接使用 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 { - // P4 阶段未实现学习路径推荐算法,返回空路径 - // P5+ 引入 AI 服务后实现 - return { points: [], recommended_order: [] }; + async getLearningPath(data: GetLearningPathRequest): Promise { + // 按学科查询学习路径,基于知识点难度升序推荐 + 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 { + // 按班级查询学习路径(班级维度不直接影响知识点排序, + // 此处按 subject_id 过滤,后续可接入班级学情数据个性化推荐) + const { points, recommendedOrder } = await this.service.getLearningPath( + data.subject_id, + ); + return { + points: points.map(toGrpcKp), + recommended_order: recommendedOrder, + }; } @GrpcMethod("KnowledgeGraphService", "AddPrerequisite") diff --git a/services/content/src/grpc/lesson-plan.grpc.controller.ts b/services/content/src/grpc/lesson-plan.grpc.controller.ts new file mode 100644 index 0000000..e5da8c5 --- /dev/null +++ b/services/content/src/grpc/lesson-plan.grpc.controller.ts @@ -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 { + 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 { + 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 { + const plan = await this.service.getById(data.id); + return toGrpcLessonPlan(plan); + } +} diff --git a/services/content/src/knowledge-points/knowledge-points.service.ts b/services/content/src/knowledge-points/knowledge-points.service.ts index 6155ea0..6485bb3 100644 --- a/services/content/src/knowledge-points/knowledge-points.service.ts +++ b/services/content/src/knowledge-points/knowledge-points.service.ts @@ -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, diff --git a/services/content/src/lesson-plans/lesson-plans.controller.ts b/services/content/src/lesson-plans/lesson-plans.controller.ts new file mode 100644 index 0000000..ca8ebc8 --- /dev/null +++ b/services/content/src/lesson-plans/lesson-plans.controller.ts @@ -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; + 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 } }; + } +} diff --git a/services/content/src/lesson-plans/lesson-plans.dto.ts b/services/content/src/lesson-plans/lesson-plans.dto.ts new file mode 100644 index 0000000..1e896bb --- /dev/null +++ b/services/content/src/lesson-plans/lesson-plans.dto.ts @@ -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; +export type UpdateLessonPlanDto = z.infer; +export type ListLessonPlansByTeacherDto = z.infer< + typeof listLessonPlansByTeacherSchema +>; +export type ListLessonPlansByStudentDto = z.infer< + typeof listLessonPlansByStudentSchema +>; diff --git a/services/content/src/lesson-plans/lesson-plans.module.ts b/services/content/src/lesson-plans/lesson-plans.module.ts new file mode 100644 index 0000000..1a24b76 --- /dev/null +++ b/services/content/src/lesson-plans/lesson-plans.module.ts @@ -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 {} diff --git a/services/content/src/lesson-plans/lesson-plans.repository.ts b/services/content/src/lesson-plans/lesson-plans.repository.ts new file mode 100644 index 0000000..207f53b --- /dev/null +++ b/services/content/src/lesson-plans/lesson-plans.repository.ts @@ -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 { + 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 { + 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 { + // 学生视角:按班级查询教师发布的备课计划 + // 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 { + await getDb().insert(lessonPlans).values(data); + } + + async update(id: string, data: Partial): Promise { + await getDb().update(lessonPlans).set(data).where(eq(lessonPlans.id, id)); + } + + async delete(id: string): Promise { + await getDb().delete(lessonPlans).where(eq(lessonPlans.id, id)); + } +} + +export const lessonPlansRepository = new LessonPlansRepository(); diff --git a/services/content/src/lesson-plans/lesson-plans.schema.ts b/services/content/src/lesson-plans/lesson-plans.schema.ts new file mode 100644 index 0000000..8e483d2 --- /dev/null +++ b/services/content/src/lesson-plans/lesson-plans.schema.ts @@ -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 | 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; diff --git a/services/content/src/lesson-plans/lesson-plans.service.ts b/services/content/src/lesson-plans/lesson-plans.service.ts new file mode 100644 index 0000000..ad69696 --- /dev/null +++ b/services/content/src/lesson-plans/lesson-plans.service.ts @@ -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 | null; +} + +export interface UpdateLessonPlanInput { + title?: string; + content?: string; + status?: string; + metadata?: Record | 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 { + 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 { + return lessonPlansRepository.findByTeacher(query); + } + + async listByStudent(query: { + studentId: string; + classId?: string; + }): Promise { + return lessonPlansRepository.findByStudent(query); + } + + async update(id: string, data: UpdateLessonPlanInput): Promise { + await this.getById(id); + await lessonPlansRepository.update(id, data); + } + + async delete(id: string): Promise { + await this.getById(id); + await lessonPlansRepository.delete(id); + } +} diff --git a/services/content/src/middleware/permission.guard.ts b/services/content/src/middleware/permission.guard.ts index 6128040..d86893f 100644 --- a/services/content/src/middleware/permission.guard.ts +++ b/services/content/src/middleware/permission.guard.ts @@ -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 = { 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 = { 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, ], }; diff --git a/services/content/src/shared/observability/logger.ts b/services/content/src/shared/observability/logger.ts index a82fbdb..37e4803 100644 --- a/services/content/src/shared/observability/logger.ts +++ b/services/content/src/shared/observability/logger.ts @@ -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,