# ISSUE-02-02 本期培训课程引入与配置 开发与验收记录 ## 1. 基线与分支信息 - **基线 Commit:** `4682df0399d86fa73105ea23ba2daee06cfb0fa0` (`integration/wave1a-foundation`) - **开发分支:** `feat/issue-02-02-plan-course` - **Worktree 路径:** `/Users/wzb/Desktop/项目/RailwaySystem-worktrees/issue-02-02-plan-course` --- ## 2. 3 个正式公开 Endpoint | 序号 | HTTP Method | Path | 权限标识 | 业务说明 | |:---|:---|:---|:---|:---| | 1 | `GET` | `/train/plans/{planId}/courses` | `train:plan:query` | 查询指定培训班本期已配置课程列表及课程元数据 | | 2 | `POST` | `/train/plans/{planId}/courses` | `train:plan:edit` | 引入课程库公共课程配置至本期计划 (UC-03) | | 3 | `DELETE` | `/train/plans/{planId}/courses/{planCourseId}` | `train:plan:edit` | 移除本期课程(受下游引用与生命周期守护) | - **控制器归属:** `com.ruoyi.web.controller.train.TrainPlanCourseController` - **应用编排归属:** `com.ruoyi.train.service.PlanAppService` (严格复用既有 11 个应用服务之一,未新增第 12 个 Application Service)。 --- ## 3. 请求与响应模型 (DTO / VO) ### 3.1 请求对象 (`PlanCourseAddDTO`) - 严格遵循 API Contract V1.0.2 契约,仅暴露以下两个 JSON 字段: - `cIds`: `List`, `@NotEmpty` (课程主键 ID 集合,绑定 `@JsonProperty("cIds")`) - `remark`: `String`, `@Size(max = 500)` (配置说明) - 经单元测试反射与 Jackson 序列化静态校验,确认不存在 `courseIds` 等多余或模糊属性。 ### 3.2 响应对象 (`PlanCourseItemVO`) - `planCourseId`: `Long` (本期课程实例唯一主键) - `planId`: `String` (培训班编号) - `cId`: `String` (课程主数据编号) - `cCode`: `String` (课程业务编码) - `cName`: `String` (课程名称) - `cType`: `String` (课程类型) - `cDuration`: `Integer` (标准学时) - `teachingObjective`: `String` (教学目标) - `sortNo`: `Integer` (排序号) - `remark`: `String` (配置说明) - `createTime`: `Date` (创建时间) --- ## 4. 生命周期规则与复用 - 严格复用 `TrainClassPlan` 冻结的生命周期与计算规则 (`effective_plan_status`): - `status = 0` (未开始) 与 `status = 1` (进行中):允许配置课程与移除课程; - `status = 2` (已结束) 与 `status = 3` (已取消):只读锁定,禁止任何增删改课程操作,抛出 `40012` (`PLAN_STATUS_LOCKED`)。 - 不在课程服务层自行发明独立的时间比对判断,确保前台视图与后台判断无状态分裂。 --- ## 5. 唯一约束与并发边界 (UNIQUE & Concurrency) - 数据库级约束:`CONSTRAINT uq_plan_course UNIQUE (plan_id, c_id)`。 - 应用层前置校验: - 请求体内自包含查重(使用 `LinkedHashSet` 校验,重复抛 `40020` `PLAN_COURSE_DUPLICATED`); - 已存在配置查重(`selectExistingCIdsByPlanId` 查重,存在交集抛 `40020` `PLAN_COURSE_DUPLICATED`)。 - 并发兜底机制: - 当高并发下两个事务同时穿透前置检查并执行批量插入时,数据库触发 `uq_plan_course` 唯一约束异常; - 应用层 `PlanAppServiceImpl` 捕获 `DuplicateKeyException` 及 `uq_plan_course` 冲突异常,精确转换为 `40020` `PLAN_COURSE_DUPLICATED`,防止裸 500 或数据库异常泄漏。 --- ## 6. 下游引用保护与删除阻断 - **课次引用 (`train_lesson`):** 若存在已排定课次,阻断删除并返回 `40021` (`PLAN_COURSE_HAS_LESSONS`); - **成绩项引用 (`train_score_item`):** 若已关联成绩考核项目,阻断删除并返回 `40022` (`PLAN_COURSE_HAS_SCORE_ITEMS`); - **外部学习记录 (`train_study_duration`):** 若已产生学习时长记录,阻断删除并抛出业务异常; - **级联删除:** 数据库外键定义为 `ON DELETE NO ACTION`,代码层严格拦截,严禁任何形式的 cascade delete。 --- ## 7. Contract Gap 登记 1. **Course 不存在 / 停用错误码缺失:** - API Contract `10_permission_and_error_codes.md` 仅针对开班计划定义了 `40410` (`PLAN_NOT_FOUND`),未给课程库分配 `COURSE_NOT_FOUND` 或 `COURSE_INACTIVE` 的专有 40xxx 错误码。 - 实现方案:抛出带明确错误信息的 `ServiceException`(消息为“课程不存在或已被删除”、“课程已被停用,无法配置到本期培训计划中”),坚决不复用或污染 `40020`。 2. **StudyDuration 下游引用错误码缺失:** - 错误码 Catalog 仅定义了 `40021` (Lessons) 与 `40022` (ScoreItems),未定义 `PLAN_COURSE_HAS_STUDY_DURATIONS` 的专属错误码。 - 实现方案:fail closed 拦截,抛出提示“该课程已产生学习记录,不可直接移除”,禁止物理级联删除。 --- ## 8. 自动化测试结果 - 单元测试套件:`com.ruoyi.train.service.impl.PlanCourseTest` 与 `PlanAppServiceImplTest` - 测试总数:35 个测试用例全部通过(0 failures, 0 errors, 0 skipped)。 - 覆盖场景: 1. 正常批量配置课程与元数据返回; 2. 请求体内自重复课程拒绝 (`40020`); 3. 数据库已配置课程重复添加拦截 (`40020`, AC-01); 4. 计划不存在拦截 (`40410`); 5. 课程不存在拦截(独立异常语义,非 40020); 6. 课程停用拦截(独立异常语义,非 40020); 7. 已结束/已取消计划拒绝修改 (`40012`); 8. 批量选课 `@Transactional` 原子事务注解验证; 9. 本期课程列表查询与字段回显 (AC-02); 10. 正常移除本期课程; 11. 锁定状态计划禁止移除课程 (`40012`); 12. 移除不存在或跨计划的课程配置拦截; 13. 存在排定课次时拒绝删除 (`40021`); 14. 存在成绩考核项时拒绝删除 (`40022`); 15. 存在外部学习时长记录时拒绝删除; 16. `PlanCourseAddDTO` 严格单一 JSON 属性契约测试; 17. 并发插入数据库唯一键冲突转换映射为 `40020` 测试; 18. 实体不变量检验(确保 `TrainClassPlan` 不含有单课程字段)。 --- ## 9. 规范与红线遵循 - **DB 真实写入:** 否(测试全部使用 JVM InMemory Fake Repository,未连接真实数据库)。 - **Generator 执行:** 否(未执行任何代码生成器覆写或额外 DDL)。 - **Commit 状态:** 未执行 `git commit` / `git merge` / `git rebase` / `git tag`。