Commit d4e9ca7bb4b6f08f22f6824d34e8b3f9003d22a9
1 parent
329a341f
docs(mod): review approval REQ-MOD-002
Showing
4 changed files
with
413 additions
and
1 deletions
docs/08-模块任务管理.md
docs/superpowers/plans/2026-05-06-REQ-MOD-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-MOD-002 | |
| 3 | +date: 2026-05-06 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-002.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-MOD-002 模块修改 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 10 | + | |
| 11 | +**Goal:** 实现 `PUT /api/modules/{id}` 接口:复用 REQ-MOD-001 已落地的 entity / mapper / common / config 体系,仅追加 ModuleUpdateDTO + Service.update + Controller 方法 + 错误码常量。 | |
| 12 | + | |
| 13 | +**Architecture:** Service 层先校验目标存在性(按 PK + bDeleted=0 查),再校验 iParentId 自引用 / 父不存在 / 父是后代环路(沿父链 walk up,深度上限 5),最后 `updateById` 落库。环路检查走"自下而上"路径,O(depth) 复杂度。 | |
| 14 | + | |
| 15 | +**Tech Stack:** 沿用 REQ-MOD-001(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Schema 改动 | |
| 20 | + | |
| 21 | +无(`tModule` schema 已由 V1 提供,本 REQ 不加列)。 | |
| 22 | + | |
| 23 | +## 文件变更清单 | |
| 24 | + | |
| 25 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_NOT_FOUND(40421, "模块不存在或已删除")` 和 `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` | |
| 26 | +- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` — PUT 入参(无 sProcedureName) | |
| 27 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `update(Integer id, ModuleUpdateDTO dto): ModuleVO` | |
| 28 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update` 方法(含父校验、环路检查、字段合并) | |
| 29 | +- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/{id}") ModuleVO update(...)` | |
| 30 | +- 创建: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` — DTO Bean Validation 单测 | |
| 31 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 `update_*` 系列单元测试(mock ModuleMapper) | |
| 32 | +- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 `put_*` 系列集成测试 | |
| 33 | + | |
| 34 | +## 任务步骤 | |
| 35 | + | |
| 36 | +### Task 1: 追加错误码常量 | |
| 37 | + | |
| 38 | +**Files:** | |
| 39 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | |
| 40 | +- Test: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`(追加断言) | |
| 41 | + | |
| 42 | +**API shape:** | |
| 43 | +- `MOD_NOT_FOUND(40421, "模块不存在或已删除")` | |
| 44 | +- `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` | |
| 45 | + | |
| 46 | +- [ ] **Step 1.1 写失败断言** | |
| 47 | + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加: | |
| 48 | + - `assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421);` | |
| 49 | + - `assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921);` | |
| 50 | + - 子会话确认 FAIL(编译错:枚举常量不存在) | |
| 51 | + | |
| 52 | +- [ ] **Step 1.2 追加枚举常量** | |
| 53 | + | |
| 54 | +- [ ] **Step 1.3 子会话确认 ApiResponseTest 全绿(5 个测试,第 5 个含新断言)** | |
| 55 | + | |
| 56 | +- [ ] **Step 1.4 提交** | |
| 57 | + - `git commit -m "feat(common): error codes for module update REQ-MOD-002"` | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +### Task 2: ModuleUpdateDTO + 校验单测 | |
| 62 | + | |
| 63 | +**Files:** | |
| 64 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` | |
| 65 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` | |
| 66 | + | |
| 67 | +**API shape:** | |
| 68 | +- 字段(与 REQ-MOD-001 的 `ModuleCreateDTO` 相比剥除 `sProcedureName`;其余 7 个字段、注解、长度规则**完全一致**): | |
| 69 | + - `@NotBlank @Pattern(...) String sDisplayType` | |
| 70 | + - `@NotBlank @Size(max=50) String sModuleType` | |
| 71 | + - `@NotBlank @Size(max=50) String sManageDeptEn` | |
| 72 | + - `Boolean bShowPermission`(可空) | |
| 73 | + - `@NotBlank @Size(max=100) String sModuleNameZh` | |
| 74 | + - `Integer iParentId`(可空) | |
| 75 | + - `@Min(0) Integer iSortOrder`(可空) | |
| 76 | + | |
| 77 | +- [ ] **Step 2.1 写失败测试(4 个)** | |
| 78 | + - `ModuleUpdateDTOValidationTest#allValidFields_yieldsNoViolations` | |
| 79 | + - `ModuleUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(5 个 @NotBlank) | |
| 80 | + - `ModuleUpdateDTOValidationTest#invalidDisplayTypeEnum_yieldsViolation` | |
| 81 | + - `ModuleUpdateDTOValidationTest#negativeSortOrder_yieldsViolation` | |
| 82 | + - 子会话: FAIL(DTO 不存在) | |
| 83 | + | |
| 84 | +- [ ] **Step 2.2 实现 ModuleUpdateDTO** | |
| 85 | + - 子会话: PASS | |
| 86 | + | |
| 87 | +- [ ] **Step 2.3 提交** | |
| 88 | + - `git commit -m "feat(mod): module update DTO REQ-MOD-002"` | |
| 89 | + | |
| 90 | +--- | |
| 91 | + | |
| 92 | +### Task 3: ModuleService.update — 业务逻辑(mock 单元测试) | |
| 93 | + | |
| 94 | +**Files:** | |
| 95 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`(追加方法签名) | |
| 96 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`(实现 + 私有 helper) | |
| 97 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 8 个测试) | |
| 98 | + | |
| 99 | +**API shape:** | |
| 100 | +- `interface ModuleService` 追加:`ModuleVO update(Integer id, ModuleUpdateDTO dto)` | |
| 101 | +- 实现步骤(写在 plan 锁定): | |
| 102 | + 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → 抛 `BizException(MOD_NOT_FOUND)` | |
| 103 | + 2. iParentId 校验(仅当 `dto.iParentId != null`): | |
| 104 | + - 等于 `id` → `BizException(MOD_PARENT_LOOP)` | |
| 105 | + - `parent = moduleMapper.selectById(dto.iParentId)`;`parent == null || parent.bDeleted` → `BizException(MOD_PARENT_NOT_FOUND)` | |
| 106 | + - **环路检查**(沿父链 walk up,从 `dto.iParentId` 出发,最多 5 层): | |
| 107 | + ``` | |
| 108 | + cur = parent; depth = 1 | |
| 109 | + while cur.iParentId != null && depth <= 5: | |
| 110 | + if cur.iParentId == id: throw MOD_PARENT_LOOP | |
| 111 | + cur = moduleMapper.selectById(cur.iParentId) | |
| 112 | + if cur == null or cur.bDeleted: break // 链断在已删除节点,视为非环 | |
| 113 | + depth += 1 | |
| 114 | + ``` | |
| 115 | + depth 超 5 仍未结束 → 视为深度违规,但 docs/03 业务注记说"深度上限 5"是预期不变量;本期不强制拦截更深,仅环路检查。 | |
| 116 | + 3. 字段合并到 `target`: | |
| 117 | + - 必填字段(5 个 @NotBlank + sDisplayType):直接覆盖(dto 为 null 不可能,validation 已挡) | |
| 118 | + - `bShowPermission`:dto 非 null 覆盖;null 保留 `target.bShowPermission` | |
| 119 | + - `iParentId`:dto 中存在该 key 即覆盖(含 null 设根)。**实现细节**:DTO 用 Integer,区分"未传"和"显式传 null"在 Spring MVC 反序列化层不区分(都是 Integer null)。本 REQ 取语义"传 null = 设根","key 缺失 = 设根"等价。 | |
| 120 | + - `iSortOrder`:dto 非 null 覆盖;null 保留 | |
| 121 | + - `sProcedureName` / `iIncrement` / `tCreateDate` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`:**完全不动**(沿用 target 上的原值) | |
| 122 | + 4. `moduleMapper.updateById(target)` | |
| 123 | + 5. `return ModuleVO.from(target)` | |
| 124 | + | |
| 125 | +- 标 `@Transactional(rollbackFor = Exception.class)` | |
| 126 | + | |
| 127 | +- [ ] **Step 3.1 写失败测试(8 个)** | |
| 128 | + - `update_targetNotFound_throws40421`:`selectById(id)` → null | |
| 129 | + - `update_targetSoftDeleted_throws40421`:`selectById(id).bDeleted=true` | |
| 130 | + - `update_parentSelfReference_throws40921`:`dto.iParentId == id` | |
| 131 | + - `update_parentNotFound_throws40411`:`selectById(parentId)` → null | |
| 132 | + - `update_parentIsDescendant_throws40921`:构造 grandparent(id)→parent→child 链,dto.iParentId=child.id | |
| 133 | + - `update_full_returnsVOWithUpdatedFields`:mock target 与 update 路径,断言传给 `updateById` 的 entity: | |
| 134 | + - 已修改字段:sDisplayType / sModuleType / sManageDeptEn / sModuleNameZh / iParentId / iSortOrder / bShowPermission | |
| 135 | + - 保持原值:sProcedureName / iIncrement / tCreateDate / sCreatedBy / bDeleted | |
| 136 | + - `update_partialNullFields_keepsOriginalValues`:dto 中 bShowPermission=null + iSortOrder=null,断言落库 entity 的对应字段保留 target 原值 | |
| 137 | + - `update_clearParent_setsParentToNull`:dto.iParentId=null,target.iParentId 原本是 7;断言 entity.iParentId == null | |
| 138 | + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor<ModuleEntity>` 捕获 `updateById` 实参 | |
| 139 | + - 子会话: FAIL(方法不存在) | |
| 140 | + | |
| 141 | +- [ ] **Step 3.2 实现 ModuleService 接口签名 + ModuleServiceImpl.update** | |
| 142 | + | |
| 143 | +- [ ] **Step 3.3 子会话确认全部 mock 单测通过** | |
| 144 | + - 全量 `mvn -B test -Dtest=ModuleServiceImplTest` 应绿(含 REQ-MOD-001 的 6 个 + 新增 8 个 = 14 个) | |
| 145 | + | |
| 146 | +- [ ] **Step 3.4 提交** | |
| 147 | + - `git commit -m "feat(mod): update module service REQ-MOD-002"` | |
| 148 | + | |
| 149 | +--- | |
| 150 | + | |
| 151 | +### Task 4: ModuleController PUT 端点 + 端到端 IT | |
| 152 | + | |
| 153 | +**Files:** | |
| 154 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | |
| 155 | +- Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`(追加 8 个集成用例) | |
| 156 | + | |
| 157 | +**API shape:** | |
| 158 | +- 类上保留 `@RequestMapping("/api/modules")` | |
| 159 | +- 新方法: | |
| 160 | + ``` | |
| 161 | + @PutMapping("/{id}") | |
| 162 | + public ApiResponse<ModuleVO> update(@PathVariable Integer id, @Valid @RequestBody ModuleUpdateDTO dto) | |
| 163 | + ``` | |
| 164 | +- 注释:`// REQ-MOD-002 模块修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")` | |
| 165 | + | |
| 166 | +- [ ] **Step 4.1 写失败测试(8 个)** | |
| 167 | + - `put_validUpdate_returns200`:先用 ModuleMapper 直接 insert 一条,再 PUT 改若干字段,断言响应 + DB 字段 | |
| 168 | + - `put_setParentToNull_clearsParent`:先建 parent + child(iParentId=parent.id),PUT child 把 iParentId=null,断言 DB 中 child.iParentId IS NULL | |
| 169 | + - `put_targetNotFound_returns40421`:`PUT /api/modules/999999` | |
| 170 | + - `put_parentNotFound_returns40411`:`iParentId=999999` | |
| 171 | + - `put_parentSelfRef_returns40921`:`PUT /api/modules/{id}` body `iParentId={id}` | |
| 172 | + - `put_parentIsDescendant_returns40921`:建 grandparent→parent→child 三层;PUT grandparent 把 iParentId=child.id | |
| 173 | + - `put_missingRequired_returns40010`:缺 sModuleNameZh | |
| 174 | + - `put_ignoresProcedureNameField_doesNotChange`:body 含 `"sProcedureName":"hijack"`,断言 DB 中 sProcedureName 仍为原值 | |
| 175 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据 | |
| 176 | + - 子会话: FAIL(端点不存在) | |
| 177 | + | |
| 178 | +- [ ] **Step 4.2 实现 PUT 端点** | |
| 179 | + - 子会话: PASS | |
| 180 | + | |
| 181 | +- [ ] **Step 4.3 跑全量 backend 测试** | |
| 182 | + - `cd backend && mvn -B test` | |
| 183 | + - 期望累计 22 + 4(DTO valid) + 8(service update) + 8(controller put) = 42 个,全绿 | |
| 184 | + | |
| 185 | +- [ ] **Step 4.4 提交** | |
| 186 | + - `git commit -m "feat(mod): PUT /api/modules/{id} controller REQ-MOD-002"` | |
| 187 | + | |
| 188 | +--- | |
| 189 | + | |
| 190 | +## 提交计划 | |
| 191 | + | |
| 192 | +- `feat(common): error codes for module update REQ-MOD-002`(覆盖 Task 1) | |
| 193 | +- `feat(mod): module update DTO REQ-MOD-002`(覆盖 Task 2) | |
| 194 | +- `feat(mod): update module service REQ-MOD-002`(覆盖 Task 3) | |
| 195 | +- `feat(mod): PUT /api/modules/{id} controller REQ-MOD-002`(覆盖 Task 4) | ... | ... |
docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-MOD-002 | |
| 3 | +date: 2026-05-06 | |
| 4 | +round: 1 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-MOD-002 — round 1 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- backend/src/main/java/com/xly/erp/module/mod/entity/ModuleEntity.java:56 — `iParentId` 改为 `FieldStrategy.IGNORED` 是 entity 全局行为变更。本期 update 走 load-then-modify 全量回填路径所以安全;但未来若有 partial updateById 路径会把 iParentId 写成 NULL。建议在字段注释加 "调用方必须 selectById 后再 updateById",或将 NULL 写入语义收敛到 update 方法本地。 | |
| 19 | +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:207 — spec § 验收 #2「正向 — 设置父模块到合法 sibling」只覆盖了 setParentToNull,没有覆盖"把 iParentId 改到另一个未删除模块"的正向写入路径。建议追加一个 IT 用例。 | |
| 20 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:630 — `update_full_returnsVOWithUpdatedFields` 的 dto.iParentId 与 target.iParentId 都是 null,断言只覆盖了 null→null。建议把 dto.setIParentId(非 null) 并 mock 合法父返回,覆盖 walk-up depth=1 直接退出循环的非环场景。 | |
| 21 | +- backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java:24 — 本 REQ 顺手把 REQ-MOD-001 那行 `// REQ-USR-004 完成后追加 @PreAuthorize(...)` 类外注释改成了 Javadoc,触及既有代码(非严格 surgical)。可读性改善但 commit message 未披露;下次类似情况建议拆 refactor commit 或在 body 注脚说明。 | |
| 22 | +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:597 — `when(moduleMapper.selectById(999999)).thenReturn(null)` 冗余(Mockito 默认就是 null),可删除。 | |
| 23 | + | |
| 24 | +## 反例 / 测试覆盖缺口 | |
| 25 | + | |
| 26 | +1. spec § 验收 #2「正向设置父模块到 sibling」在 IT 与单元两层都缺成功路径用例;只覆盖了"清空父"和各种 parent 校验失败用例,未直接验证 iParentId 从 null/某值改到另一个有效模块后 DB 实际写入了新父 id 的主线路径。 | |
| 27 | +2. spec § 验收 #6「目标已软删除」仅在单元层覆盖(`update_targetSoftDeleted_throws40421`),IT 缺一个"先 update bDeleted=1 再 PUT 返回 40421"用例。 | |
| 28 | +3. spec § 验收 #8(枚举非法 `sDisplayType="X"`)/ #9(`sModuleType=51 字符`)只在 `ModuleUpdateDTOValidationTest` 单元层验证,未在 IT 走一遍 PUT 端到端断言 40010。 | |
| 29 | +4. 环路检查的"4-5 层深度边界"和"父链中存在已软删除节点导致提前 break 的非环场景"未单独覆盖;本期数据量低可接受。 | |
| 30 | +5. `FieldStrategy.IGNORED` 是 entity 全局变更,对未来其他 service 走 partial `updateById` 路径会埋雷;建议要么文档化要么把 NULL 写入收敛到本地策略。 | |
| 31 | +6. 未发现硬编码凭据、未跨模块改动、未引入技术栈外组件、commit 全部带 `REQ-MOD-002` 标签、响应不回显堆栈——全部合规。错误码(40010/40411/40421/40921)与 docs/05 / spec 一致。环路检查实现 walk-up 沿父链最多 5 层 + 已删节点 break,逻辑正确。 | ... | ... |
docs/superpowers/specs/2026-05-06-REQ-MOD-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-MOD-002 | |
| 3 | +date: 2026-05-06 | |
| 4 | +module: module_mod | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-MOD-002 — 模块修改 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +实现后端 `PUT /api/modules/{id}` 接口:在不破坏唯一性 / 树结构完整性的前提下,更新已有模块的可编辑字段,返回最新模块 VO。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +**接口**:`PUT /api/modules/{id}`,Content-Type `application/json`。`{id}` = `tModule.iIncrement`。 | |
| 16 | + | |
| 17 | +**Request body**(`ModuleUpdateDTO`)字段——与 REQ-MOD-001 输入相比**剥除 `sProcedureName`**(不可改,contract 约束);其余 7 个业务字段含义和校验规则保持一致: | |
| 18 | + | |
| 19 | +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | | |
| 20 | +|---|---|---|---|---| | |
| 21 | +| `sDisplayType` | String | 是 | 枚举:`手机端` / `前端业务` / `系统配置` / `接口` | `tModule.sDisplayType` | | |
| 22 | +| `sModuleType` | String | 是 | 长度 1-50 | `tModule.sModuleType` | | |
| 23 | +| `sManageDeptEn` | String | 是 | 长度 1-50 | `tModule.sManageDeptEn` | | |
| 24 | +| `bShowPermission` | Boolean | 否 | 默认保持原值;显式传 `null` 视为不变 | `tModule.bShowPermission` | | |
| 25 | +| `sModuleNameZh` | String | 是 | 长度 1-100 | `tModule.sModuleNameZh` | | |
| 26 | +| `iParentId` | Integer | 否 | 可空(设为根模块);非空必须存在且未软删除;不能等于 `{id}` 自身或其后代 | `tModule.iParentId` | | |
| 27 | +| `iSortOrder` | Integer | 否 | 默认保持原值;非负整数 | `tModule.iSortOrder` | | |
| 28 | + | |
| 29 | +> **`sProcedureName` 不在 DTO 中**:Jackson 反序列化时若客户端误传将被忽略(`@JsonIgnoreProperties(ignoreUnknown = true)` 由 Jackson 默认行为兜底;不抛错)。前端 UI 应把该字段渲染为只读。 | |
| 30 | +> | |
| 31 | +> **PUT 语义**:本接口采用全量替换语义。请求体中显式存在的字段均落库;若未提供(JSON 中 key 缺失或值为 `null`),按字段下方"必填"列:必填字段缺失 → `40010`;可选字段缺失 → 保持数据库原值。 | |
| 32 | + | |
| 33 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + 权限码 `MOD:UPDATE`。本 REQ 沿用 REQ-MOD-001 的 SecurityConfig permitAll 占位(REQ-USR-004 后回头收紧);Controller 写注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。 | |
| 34 | + | |
| 35 | +## 输出 / 结果 | |
| 36 | + | |
| 37 | +**HTTP 200,响应体**(统一响应格式): | |
| 38 | + | |
| 39 | +```json | |
| 40 | +{ | |
| 41 | + "code": 200, | |
| 42 | + "message": "操作成功", | |
| 43 | + "data": { | |
| 44 | + "iIncrement": 12, | |
| 45 | + "sDisplayType": "前端业务", | |
| 46 | + "sProcedureName": "sp_audit_user_module", | |
| 47 | + "sModuleType": "USR", | |
| 48 | + "sManageDeptEn": "IT", | |
| 49 | + "bShowPermission": true, | |
| 50 | + "sModuleNameZh": "用户管理(修订)", | |
| 51 | + "iParentId": 3, | |
| 52 | + "iSortOrder": 5, | |
| 53 | + "tCreateDate": "2026-05-06T10:30:00", | |
| 54 | + "bDeleted": false | |
| 55 | + }, | |
| 56 | + "timestamp": 1746528600000 | |
| 57 | +} | |
| 58 | +``` | |
| 59 | + | |
| 60 | +VO 复用 REQ-MOD-001 的 `ModuleVO`(11 个字段)。 | |
| 61 | + | |
| 62 | +## 业务规则 | |
| 63 | + | |
| 64 | +1. **目标模块必须存在且未软删除**:`SELECT ... WHERE iIncrement = {id} AND bDeleted = 0`。不存在或已删 → `40421`。 | |
| 65 | +2. **`sProcedureName` 不可改**:DTO 不接受该字段;后端读取目标记录后保留原 `sProcedureName` 不变。 | |
| 66 | +3. **`iParentId` 自引用校验**: | |
| 67 | + - 若 `iParentId` 等于路径参数 `{id}`(自引用)→ `40921`。 | |
| 68 | + - 若 `iParentId` 在 `tModule` 中不存在或已软删除 → `40411`。 | |
| 69 | + - 若 `iParentId` 是 `{id}` 的后代(沿 `iParentId` 链向下走,深度上限 5 层与 docs/03 § tModule 业务注记一致)→ `40921`。 | |
| 70 | +4. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。 | |
| 71 | +5. **`bShowPermission` / `iSortOrder` 部分更新**:DTO 中为 `null` → 保持原值;显式传值 → 覆盖。 | |
| 72 | +6. **审计**:本 REQ 暂不维护"最近修改时间"和"修改人"列(schema 未规划相关字段,docs/03 也未要求)。后续若需,按 V_n migration 加列同步更新 docs/03。 | |
| 73 | +7. **多租户字段不写入**:与 REQ-MOD-001 一致,本接口不动 `sBrandsId / sSubsidiaryId`。 | |
| 74 | + | |
| 75 | +## 边界与约束 | |
| 76 | + | |
| 77 | +### 鉴权策略(本 REQ 限定) | |
| 78 | + | |
| 79 | +沿用 REQ-MOD-001:SecurityConfig permitAll;Controller 上写说明性注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。 | |
| 80 | + | |
| 81 | +### 事务 | |
| 82 | + | |
| 83 | +- Service 方法标 `@Transactional(rollbackFor = Exception.class)`。读取目标模块 → 校验 → 更新 全在同一事务。 | |
| 84 | +- 父模块校验 + 后代环路检查需多次 `selectById`,事务内可能产生几次小查询;本期数据量低,不做缓存优化。 | |
| 85 | + | |
| 86 | +### 并发 | |
| 87 | + | |
| 88 | +- 用 `moduleMapper.updateById(entity)` 走 PK 更新;不引入乐观锁版本号(schema 没规划 `version` 列)。 | |
| 89 | +- 并发同时更新同一模块时遵循"后写覆盖"语义,可接受。需要更强一致性时另开 REQ。 | |
| 90 | + | |
| 91 | +### 性能 | |
| 92 | + | |
| 93 | +- 后代环路检查用迭代 BFS(队列),每次查 `selectList(eq("iParentId", ...))` 拿子节点;深度上限 5 层 + 单层节点数受限于业务,不做递归 SQL。 | |
| 94 | + | |
| 95 | +### 错误码映射(与 docs/05 对齐) | |
| 96 | + | |
| 97 | +| 场景 | 错误码 | | |
| 98 | +|---|---| | |
| 99 | +| 必填字段缺失 / 类型错误 / 长度超限 / 枚举非法 | `40010` | | |
| 100 | +| `{id}` 模块不存在或已软删除 | `40421` | | |
| 101 | +| `iParentId` 指向不存在 / 已删模块 | `40411` | | |
| 102 | +| `iParentId == {id}` 或为 `{id}` 的后代 | `40921` | | |
| 103 | +| 服务端兜底 | `50000` | | |
| 104 | + | |
| 105 | +> docs/05 列出的 `40911`(sProcedureName 冲突)在本实现里不会触发(DTO 不接受 sProcedureName);保留契约文档不变即可。 | |
| 106 | +> 新增错误码 `40921` 需补到 `ErrorCode` 枚举(命名 `MOD_PARENT_LOOP`);`40421` 命名 `MOD_NOT_FOUND`。 | |
| 107 | + | |
| 108 | +## 依赖的 schema 表 / 字段 | |
| 109 | + | |
| 110 | +**写表**:`tModule`(详见 docs/03 § tModule) | |
| 111 | + | |
| 112 | +| 字段 | 行为 | | |
| 113 | +|---|---| | |
| 114 | +| `iIncrement` | 路径参数 `{id}` 定位行,**不修改** | | |
| 115 | +| `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` | **不修改** | | |
| 116 | +| `sDisplayType` | 入参覆盖 | | |
| 117 | +| `sProcedureName` | **不修改**(保留原值) | | |
| 118 | +| `sModuleType` | 入参覆盖 | | |
| 119 | +| `sManageDeptEn` | 入参覆盖 | | |
| 120 | +| `bShowPermission` | 入参非 null 覆盖;null 保留 | | |
| 121 | +| `sModuleNameZh` | 入参覆盖 | | |
| 122 | +| `iParentId` | 入参覆盖(含 null 设根) | | |
| 123 | +| `iSortOrder` | 入参非 null 覆盖;null 保留 | | |
| 124 | +| `bDeleted` / `tDeletedDate` / `sDeletedBy` | **不修改** | | |
| 125 | + | |
| 126 | +**索引利用**: | |
| 127 | +- 主键定位 `{id}` | |
| 128 | +- `idx_parent` / `fk_module_parent`:iParentId 校验时按父链 / 子链查询 | |
| 129 | + | |
| 130 | +**外键**:`fk_module_parent` 仍兜底;应用层环路检查在写入前显式拦截。 | |
| 131 | + | |
| 132 | +## 依赖的接口 | |
| 133 | + | |
| 134 | +无(本接口独立工作;与 REQ-MOD-001 并列同模块同 schema)。 | |
| 135 | + | |
| 136 | +## 验收标准 | |
| 137 | + | |
| 138 | +### 功能正确性 | |
| 139 | + | |
| 140 | +1. **正向 — 全量更新非父字段**:传入合法的 7 个字段(不含 `iParentId` 自引用),返回 200 + 最新 VO;DB 中查询新值与入参一致;`sProcedureName` / `tCreateDate` 与原值相同。 | |
| 141 | +2. **正向 — 设置父模块**:先建 root + child,再 `PUT /api/modules/{child_id}` 把 `iParentId` 改到另一个 sibling;返回 200,DB 中 `iParentId` 更新成功。 | |
| 142 | +3. **正向 — 清空父模块(设为根)**:`PUT` 时显式传 `"iParentId": null`,DB 中 `iParentId` 变 NULL。 | |
| 143 | +4. **正向 — 部分字段保留原值**:DTO 中 `bShowPermission` / `iSortOrder` 传 null,DB 中保留原值。 | |
| 144 | +5. **目标不存在**:`PUT /api/modules/999999`,返回 200 + `code=40421`。 | |
| 145 | +6. **目标已软删除**:先把模块 `bDeleted` 置 1(直接 DB UPDATE 模拟),再 `PUT`,返回 `40421`。 | |
| 146 | +7. **必填缺失**:DTO 缺 `sModuleNameZh`,返回 `40010`。 | |
| 147 | +8. **枚举非法**:`sDisplayType="X"`,返回 `40010`。 | |
| 148 | +9. **长度超限**:`sModuleType` = 51 字符,返回 `40010`。 | |
| 149 | +10. **iParentId 自引用**:`PUT /api/modules/{id}` 把 `iParentId` 设为 `{id}` 本身,返回 `40921`。 | |
| 150 | +11. **iParentId 不存在**:`PUT` 时 `iParentId=999999`,返回 `40411`。 | |
| 151 | +12. **iParentId 是后代**:祖父→父→子三层结构,`PUT` 祖父把 `iParentId` 设为子的 id,返回 `40921`。 | |
| 152 | +13. **sProcedureName 字段被忽略**:客户端误传 `sProcedureName="other"`,DB 中该字段保持原值。 | |
| 153 | + | |
| 154 | +### 接口契约一致性 | |
| 155 | + | |
| 156 | +- 响应格式严格符合 `{code, message, data, timestamp}`(docs/05 § 全局约定)。 | |
| 157 | +- 错误码段位与 docs/05 一致:`40010` / `40411` / `40421` / `40921` / `50000`。 | |
| 158 | +- 异常堆栈不出现在响应里。 | |
| 159 | + | |
| 160 | +### 测试覆盖 | |
| 161 | + | |
| 162 | +- **单元测试** `ModuleServiceImplTest`(继续 mock ModuleMapper): | |
| 163 | + - update_targetNotFound_throws40421 | |
| 164 | + - update_targetSoftDeleted_throws40421 | |
| 165 | + - update_parentSelfReference_throws40921 | |
| 166 | + - update_parentNotFound_throws40411 | |
| 167 | + - update_parentIsDescendant_throws40921 | |
| 168 | + - update_full_returnsVOWithUpdatedFields(断言传给 mapper.updateById 的 entity 字段值,包括 sProcedureName 保留) | |
| 169 | + - update_partialNullFields_keepsOriginalValues | |
| 170 | + - update_clearParent_setsParentToNull | |
| 171 | + | |
| 172 | +- **集成测试** `ModuleControllerIT` 追加(`@Transactional` 自动回滚;用 ModuleMapper 直接预置数据): | |
| 173 | + - put_validUpdate_returns200 | |
| 174 | + - put_setParentToNull_clearsParent | |
| 175 | + - put_targetNotFound_returns40421 | |
| 176 | + - put_parentNotFound_returns40411 | |
| 177 | + - put_parentSelfRef_returns40921 | |
| 178 | + - put_parentIsDescendant_returns40921 | |
| 179 | + - put_missingRequired_returns40010 | |
| 180 | + - put_ignoresProcedureNameField_doesNotChange | |
| 181 | + | |
| 182 | +### 代码与文档 | |
| 183 | + | |
| 184 | +- `// REQ-MOD-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode 枚举常量上。 | |
| 185 | +- 提交按 `feat(mod): <subject> REQ-MOD-002` 规范,每 Task 一个 commit。 | |
| 186 | +- 不引入 docs/04 § 零 技术栈外的依赖。 | ... | ... |