Commit 7429b0f9dd89a455167a4b783c175dc00eee8bf9
1 parent
49f72e9f
docs(mod): spec + plan REQ-MOD-002
Showing
2 changed files
with
355 additions
and
0 deletions
docs/superpowers/plans/2026-04-29-REQ-MOD-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-MOD-002 | |
| 3 | +date: 2026-04-29 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-04-29-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:** 在 MOD-001 已有工程基础上增量实现 `PUT /api/mod/modules/{id}`,更新 7 个可编辑字段,保留 `sProcedureName` / `sCreatedBy` / 标准列;含目标存在性、枚举、父链合法性(自指 / 不存在 / 环)四类校验。 | |
| 12 | + | |
| 13 | +**Architecture:** 复用 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` / `Module entity`,新增 `UpdateModuleDTO`、`ModuleService#update`、controller `@PutMapping`、`mapper.selectParentIdById`(轻量父链查询)。SecurityConfig 路径白名单从 `POST /api/mod/modules` 扩展为 `/api/mod/**` 以覆盖 MOD-002~004。环检测在 service 层用循环回溯,最大深度 50。 | |
| 14 | + | |
| 15 | +**Tech Stack:** Spring Boot 3.3.5 / MyBatis-Plus / Spring Security(已有);JUnit 5 + Mockito + TestRestTemplate(已有)。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Schema 改动 | |
| 20 | + | |
| 21 | +无(`tModule` schema 已满足;本 REQ 仅 UPDATE 操作)。 | |
| 22 | + | |
| 23 | +## 文件变更清单 | |
| 24 | + | |
| 25 | +### 新增 | |
| 26 | + | |
| 27 | +- `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java` — 入参 DTO(无 `sProcedureName` 字段) | |
| 28 | + | |
| 29 | +### 修改 | |
| 30 | + | |
| 31 | +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — `requestMatchers(POST, "/api/mod/modules")` → `requestMatchers("/api/mod/**")`,stub 注释保持 | |
| 32 | +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `Integer selectParentIdById(Integer id)` 注解 SELECT 方法(仅查 iParentId 列,不取整行) | |
| 33 | +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `Integer update(Integer id, UpdateModuleDTO dto)` 方法 | |
| 34 | +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update(...)`:目标存在性 → 枚举 → iParentId 三重校验 → mapper.updateById | |
| 35 | +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/modules/{id}")` 端点 | |
| 36 | +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 7 个 update 用例 | |
| 37 | +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 PUT IT 用例 | |
| 38 | +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 个 `selectParentIdById` 用例 | |
| 39 | + | |
| 40 | +## 任务步骤 | |
| 41 | + | |
| 42 | +> 全局约束:每 commit 形如 `<type>(mod): <subject> REQ-MOD-002`;测试派发到子会话执行;现有 26 用例全程保持绿。 | |
| 43 | + | |
| 44 | +### Task 1: SecurityConfig 路径白名单扩范围 | |
| 45 | + | |
| 46 | +**Files:** | |
| 47 | +- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java:25` | |
| 48 | + | |
| 49 | +**API shape:** | |
| 50 | +- 改一行:`requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll()` → `requestMatchers("/api/mod/**").permitAll()` | |
| 51 | +- 注释保留 `// REQ-MOD-001 stub: see USR-004 follow-up`(语义不变,覆盖范围扩大) | |
| 52 | + | |
| 53 | +- [ ] **Step 1: 修改 SecurityConfig** | |
| 54 | + - 单行 Edit;不动任何其他类 | |
| 55 | + | |
| 56 | +- [ ] **Step 2: 子会话验证 PASS** | |
| 57 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleControllerIT` | |
| 58 | + - 期望:现有 7 用例全绿(permitAll 范围扩大不收紧已有路径) | |
| 59 | + | |
| 60 | +- [ ] **Step 3: Commit** | |
| 61 | + - `git commit -m "refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002"` | |
| 62 | + | |
| 63 | +### Task 2: ModuleMapper 追加父 ID 查询 | |
| 64 | + | |
| 65 | +**Files:** | |
| 66 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` | |
| 67 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` | |
| 68 | + | |
| 69 | +**API shape:** | |
| 70 | +- `@Select("SELECT iParentId FROM tModule WHERE iIncrement = #{id} AND bDeleted = 0")` `Integer selectParentIdById(@Param("id") Integer id)` | |
| 71 | + - 命中行的 `iParentId` 可能为 NULL(根模块)→ 返回 null | |
| 72 | + - 未命中(不存在 / 已软删)→ 返回 null(与"根模块"在调用方语义不同,需要调用方明确校验存在性) | |
| 73 | + | |
| 74 | +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#selectParentIdById_returnsNullForRootOrMissing_andValueForChild`** | |
| 75 | + - 准备 3 行:root(iParentId=NULL)、child(iParentId=root)、deleted(iParentId=root, bDeleted=1) | |
| 76 | + - 断言:`selectParentIdById(root.id) == null`;`selectParentIdById(child.id) == root.id`;`selectParentIdById(deleted.id) == null`;`selectParentIdById(99999999) == null` | |
| 77 | + | |
| 78 | +- [ ] **Step 2: 实现 mapper 方法** | |
| 79 | + | |
| 80 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 81 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` | |
| 82 | + | |
| 83 | +- [ ] **Step 4: Commit** | |
| 84 | + - `git commit -m "feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002"` | |
| 85 | + | |
| 86 | +### Task 3: UpdateModuleDTO + Service.update 合法路径 | |
| 87 | + | |
| 88 | +**Files:** | |
| 89 | +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java` | |
| 90 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` | |
| 91 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | |
| 92 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | |
| 93 | + | |
| 94 | +**API shape:** | |
| 95 | +- `UpdateModuleDTO` 字段(带 `@JsonProperty` 锁定 JSON 名 + Bean Validation): | |
| 96 | + - `@NotBlank String sDisplayType` | |
| 97 | + - `@NotBlank @Size(max=50) String sModuleType` | |
| 98 | + - `@NotBlank @Size(max=50) String sManageDeptEn` | |
| 99 | + - `Boolean bShowPermission`(可空) | |
| 100 | + - `@NotBlank @Size(max=100) String sModuleNameZh` | |
| 101 | + - `Integer iParentId`(可空) | |
| 102 | + - `Integer iSortOrder`(可空) | |
| 103 | + - **不含 `sProcedureName`** | |
| 104 | +- `ModuleService#update(Integer id, UpdateModuleDTO dto) : Integer` | |
| 105 | +- `ModuleServiceImpl#update`: | |
| 106 | + 1. `Module original = moduleMapper.selectById(id)`;若 null 或 `original.getBDeleted()==true` → `BizException(40400, "模块不存在或已删除")` | |
| 107 | + 2. 枚举校验同 MOD-001 → 40010 | |
| 108 | + 3. `iParentId` 校验(4 类)(在本 task 不全实现,仅留 hook,详见 Task 4):本 task 暂只对 null/合法路径走通;非 null 时调 `existsActiveById` 但暂不做自指/环检测,留 Task 4 加。 | |
| 109 | + 4. 构造 `Module entity`:仅 set `iIncrement` 和可改 7 字段(其余 null);`bShowPermission` null → false | |
| 110 | + 5. `moduleMapper.updateById(entity)` 返回 `id` | |
| 111 | + | |
| 112 | +- [ ] **Step 1: 写失败测试** | |
| 113 | + - 在 `ModuleServiceImplTest` 追加 3 用例: | |
| 114 | + - `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` | |
| 115 | + - `updateWithTargetNotFound_throws40400` | |
| 116 | + - `updateWithBShowPermissionNull_setsFalseInEntity` | |
| 117 | + - mock 准备:`moduleMapper.selectById(id)` 返回 stub Module(含原 sProcedureName / sCreatedBy);`moduleMapper.updateById(any(Module.class))` 返回 1 | |
| 118 | + - ArgumentCaptor 抓传给 `updateById` 的 entity,断言:`iIncrement` 是路径 id;`sProcedureName == null`;`sCreatedBy == null`;`tCreateDate == null`;`sBrandsId == null`;`sSubsidiaryId == null`;可改字段被透传 | |
| 119 | + - 子会话先跑 → FAIL(方法不存在) | |
| 120 | + | |
| 121 | +- [ ] **Step 2: 实现 DTO + Service** | |
| 122 | + - DTO 与 CreateModuleDTO 平行结构;Service 实现仅覆盖 Task 3 三个用例所需逻辑 | |
| 123 | + | |
| 124 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 125 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | |
| 126 | + - 期望:6 (MOD-001) + 3 (本 task) = 9 用例全绿 | |
| 127 | + | |
| 128 | +- [ ] **Step 4: Commit** | |
| 129 | + - `git commit -m "feat(mod): module update dto + service happy path REQ-MOD-002"` | |
| 130 | + | |
| 131 | +### Task 4: Service 校验分支(枚举 + iParentId 三类) | |
| 132 | + | |
| 133 | +**Files:** | |
| 134 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` | |
| 135 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` | |
| 136 | + | |
| 137 | +**API shape:** 不变(仅补 update 方法内部校验逻辑) | |
| 138 | + | |
| 139 | +**校验顺序**(service 实现): | |
| 140 | + | |
| 141 | +1. 目标存在 / 枚举(已在 Task 3 实现) | |
| 142 | +2. 若 `iParentId != null`: | |
| 143 | + a. `iParentId.equals(id)` → `BizException(40021, "父模块不能指向自身")` | |
| 144 | + b. `!moduleMapper.existsActiveById(iParentId)` → `BizException(40021, "父模块不存在或已删除")` | |
| 145 | + c. 环检测:`Integer cur = iParentId; for (int depth = 0; cur != null && depth < 50; depth++) { if (cur.equals(id)) throw BizException(40021,"父模块链构成环路"); cur = moduleMapper.selectParentIdById(cur); }`;若 depth 达到 50 → `BizException(40021, "父模块链超过最大层级")` | |
| 146 | + | |
| 147 | +- [ ] **Step 1: 写失败测试** | |
| 148 | + - 在 `ModuleServiceImplTest` 追加 4 用例: | |
| 149 | + - `updateWithInvalidDisplayType_throws40010` | |
| 150 | + - `updateWithSelfParentId_throws40021`(msg contains "自身") | |
| 151 | + - `updateWithMissingParent_throws40021`(msg contains "父模块不存在") | |
| 152 | + - `updateWithCyclicParent_throws40021`(msg contains "环路";mock 编排:`existsActiveById(parent)=true`;`selectParentIdById(parent) = id`) | |
| 153 | + - 子会话先跑 → 4 用例 FAIL | |
| 154 | + | |
| 155 | +- [ ] **Step 2: 实现校验分支** | |
| 156 | + - 严格按上述 a/b/c 顺序,**a 在 b 之前**(自指应当先于存在性,否则用户传自身 id 在父表里又恰好不存在时报错信息会误导) | |
| 157 | + | |
| 158 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 159 | + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` | |
| 160 | + - 期望:6 + 3 + 4 = 13 用例全绿 | |
| 161 | + | |
| 162 | +- [ ] **Step 4: Commit** | |
| 163 | + - `git commit -m "feat(mod): module update parent validation REQ-MOD-002"` | |
| 164 | + | |
| 165 | +### Task 5: ModuleController PUT + IT 正常路径 | |
| 166 | + | |
| 167 | +**Files:** | |
| 168 | +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` | |
| 169 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | |
| 170 | + | |
| 171 | +**API shape:** | |
| 172 | +- `@PutMapping("/modules/{id}") public Result<Map<String,Integer>> update(@PathVariable Integer id, @Valid @RequestBody UpdateModuleDTO dto)` | |
| 173 | +- 返回 `Result.ok(Map.of("iIncrement", moduleService.update(id, dto)))` | |
| 174 | + | |
| 175 | +- [ ] **Step 1: 写失败测试 `ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields`** | |
| 176 | + - 步骤:① JdbcTemplate 直插一行原始数据(含 sProcedureName="sp_test_orig"、sCreatedBy="ORIG_USER"、sBrandsId="XLY"、sModuleNameZh="原名");② PUT body 改 sModuleNameZh="新名"、sDisplayType="前端业务";带 JWT="ADMIN001";③ 期望 `code=0`,`data.iIncrement` 等于 ① 的 id;④ JdbcTemplate 查行:`sModuleNameZh="新名"`、`sDisplayType="前端业务"`、`sProcedureName="sp_test_orig"`(保留)、`sCreatedBy="ORIG_USER"`(保留) | |
| 177 | + | |
| 178 | +- [ ] **Step 2: 实现 controller PUT** | |
| 179 | + - 与 POST 同结构 | |
| 180 | + | |
| 181 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 182 | + - 命令:`cd backend && mvn -B test -Dtest='ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields'` | |
| 183 | + | |
| 184 | +- [ ] **Step 4: Commit** | |
| 185 | + - `git commit -m "feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002"` | |
| 186 | + | |
| 187 | +### Task 6: IT 异常路径补全 + 全量回归 | |
| 188 | + | |
| 189 | +**Files:** | |
| 190 | +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` | |
| 191 | + | |
| 192 | +**API shape:** 不新增(覆盖 PUT 接口 6 条异常路径) | |
| 193 | + | |
| 194 | +- [ ] **Step 1: 在 IT 中追加 6 个用例** | |
| 195 | + - `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999` body 合法 → `code=40400` | |
| 196 | + - `putInvalidDisplayType_returns40010` — `sDisplayType="火星"` → `code=40010` | |
| 197 | + - `putSelfParent_returns40021` — body `iParentId == path id` → `code=40021` | |
| 198 | + - `putCyclicParent_returns40021` — 准备:先插 root,再插 child(parent=root);PUT root 把 `iParentId=child.id` → `code=40021` | |
| 199 | + - `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先插原行(sCreatedBy="ORIG_USER"),无 token PUT;期望 `code=0`,DB 中 sCreatedBy 仍为 "ORIG_USER"(不被覆盖为 STUB_ADMIN) | |
| 200 | + - `putTamperedJwt_returns20001` — Authorization 头 `Bearer not.a.real.jwt` → `code=20001` | |
| 201 | + - 6 个用例先跑 → 期望 FAIL(部分分支需 Task 4/5 已经覆盖;这里主要补 IT 端到端) | |
| 202 | + | |
| 203 | +- [ ] **Step 2: 让测试通过** | |
| 204 | + - service / controller / config 已实现;本 task 主要排查 RestTemplate 行为(4xx 是否抛、URL 拼接、JSON parse);不应当为让测试通过新增业务分支 | |
| 205 | + | |
| 206 | +- [ ] **Step 3: 子会话跑全量回归** | |
| 207 | + - 命令:`cd backend && mvn -B test` | |
| 208 | + - 期望:MOD-001 26 用例 + MOD-002 新增 1(mapperIT) + 7(serviceTest) + 7(controllerIT) = 41 用例全绿 | |
| 209 | + | |
| 210 | +- [ ] **Step 4: Commit** | |
| 211 | + - `git commit -m "test(mod): module update integration coverage REQ-MOD-002"` | |
| 212 | + | |
| 213 | +## 提交计划 | |
| 214 | + | |
| 215 | +| commit | 覆盖 | | |
| 216 | +|---|---| | |
| 217 | +| `refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002` | Task 1 | | |
| 218 | +| `feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002` | Task 2 | | |
| 219 | +| `feat(mod): module update dto + service happy path REQ-MOD-002` | Task 3 | | |
| 220 | +| `feat(mod): module update parent validation REQ-MOD-002` | Task 4 | | |
| 221 | +| `feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002` | Task 5 | | |
| 222 | +| `test(mod): module update integration coverage REQ-MOD-002` | Task 6 | | ... | ... |
docs/superpowers/specs/2026-04-29-REQ-MOD-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-MOD-002 | |
| 3 | +date: 2026-04-29 | |
| 4 | +module: module_mod | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-MOD-002 — 模块修改 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +在不破坏唯一性的前提下,更新已有模块的可编辑字段:`sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder`。`sProcedureName`、`sCreatedBy`、`tCreateDate`、`sBrandsId` / `sSubsidiaryId`、软删除字段一律保留原值。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +### HTTP 接口(来自 docs/05 § REQ-MOD-002) | |
| 16 | + | |
| 17 | +- Method / Path: `PUT /api/mod/modules/{id}`(`{id}` = `tModule.iIncrement`) | |
| 18 | +- Auth: 必需(JWT Bearer) | |
| 19 | +- Permission: 仅超级管理员(**沿用 MOD-001 的 stub:SecurityConfig 路径范围扩展为 `/api/mod/**` permitAll,USR-004 完成后统一改 `hasAuthority('SUPER_ADMIN')`**) | |
| 20 | + | |
| 21 | +### 请求 DTO `UpdateModuleDTO` | |
| 22 | + | |
| 23 | +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | | |
| 24 | +|---|---|---|---|---| | |
| 25 | +| `sDisplayType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[手机端, 前端业务, 系统配置, 接口]` 内;非法 → `40010` | | |
| 26 | +| `sModuleType` | `String` | 是 | `@NotBlank @Size(max=50)` | 自由文本 | | |
| 27 | +| `sManageDeptEn` | `String` | 是 | `@NotBlank @Size(max=50)` | — | | |
| 28 | +| `bShowPermission` | `Boolean` | 否 | — | 缺省视为 `false`(写 0) | | |
| 29 | +| `sModuleNameZh` | `String` | 是 | `@NotBlank @Size(max=100)` | — | | |
| 30 | +| `iParentId` | `Integer` | 否 | — | 不为 null 时:① 不能等于路径 `{id}`(自指);② 必须命中存在且 `bDeleted=0` 的记录;③ 沿父链遍历不能在路径中出现 `{id}`(环检测)。三种违反统一 → `40021` | | |
| 31 | +| `iSortOrder` | `Integer` | 否 | — | 缺省 `0` | | |
| 32 | + | |
| 33 | +> **`sProcedureName` 显式从 DTO 中剔除**——API 契约声明该字段不可改;前端表单仍可显示原值(REQ 卡列为必填仅是前端 UX 约束),但 PUT body 中即便传了也会被 Jackson 丢弃,不进入 service。这一处与 REQ 卡输入表的差异是**有意的**:以 docs/05 API 契约为准。 | |
| 34 | + | |
| 35 | +### 鉴权与上下文 | |
| 36 | + | |
| 37 | +同 MOD-001:JWT Filter 解析 token 写 `principal=sUserNo`;本 REQ 走 `permitAll` stub,不强制要求 token;伪造 token 仍被 filter 短路返回 `code=20001`。`sCreatedBy` 在更新时**不修改**,无论是否携带 token。 | |
| 38 | + | |
| 39 | +## 输出 / 结果 | |
| 40 | + | |
| 41 | +### 成功响应 | |
| 42 | + | |
| 43 | +```json | |
| 44 | +{ | |
| 45 | + "code": 0, | |
| 46 | + "msg": "ok", | |
| 47 | + "data": { "iIncrement": 123 } | |
| 48 | +} | |
| 49 | +``` | |
| 50 | + | |
| 51 | +### 持久化效果 | |
| 52 | + | |
| 53 | +`UPDATE tModule SET <可编辑列> WHERE iIncrement = {id}`。 | |
| 54 | + | |
| 55 | +| 字段 | 更新策略 | | |
| 56 | +|---|---| | |
| 57 | +| `sDisplayType` / `sModuleType` / `sManageDeptEn` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | | |
| 58 | +| `bShowPermission` | DTO null → `false`,否则 DTO 值 | | |
| 59 | +| `sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `bDeleted` / `tDeletedDate` / `sDeletedBy` / `sId` | **不更新**(entity 上对应字段保持 null,依赖 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL` 跳过 null 字段) | | |
| 60 | + | |
| 61 | +> 实施细节:MyBatis-Plus 默认全局 `update-strategy: NOT_NULL`,但需在 entity 字段上不显式标注其他 strategy。`bShowPermission` 因 DTO null 时要写 false,故 service 层先把 DTO null 展开为 false 再赋值给 entity;其余 null 字段保持 null。 | |
| 62 | + | |
| 63 | +## 业务规则 | |
| 64 | + | |
| 65 | +1. **目标存在性**:先 `SELECT * FROM tModule WHERE iIncrement = {id} AND bDeleted = 0`;找不到 → `BizException(40400, "模块不存在或已删除")`。 | |
| 66 | +2. **枚举校验**:`sDisplayType` 在 `DISPLAY_TYPES` 内(复用 `ModuleServiceImpl.DISPLAY_TYPES`);非法 → `BizException(40010, "显示类型枚举不合法")`。 | |
| 67 | + > docs/05 § MOD-002 错误码表只列了 40001/40021/40400,未单列 40010。本实现选择复用 MOD-001 已建立的 40010 语义(保持同一字段两个接口的错误码一致)。这条偏离已记入 spec,后续若需统一可在 docs/05 补一行。 | |
| 68 | +3. **iParentId 校验**(`iParentId != null` 时): | |
| 69 | + - 自指 (`iParentId == id`) → `BizException(40021, "父模块不能指向自身")` | |
| 70 | + - 不存在 / 已软删 (`!moduleMapper.existsActiveById(iParentId)`) → `BizException(40021, "父模块不存在或已删除")` | |
| 71 | + - 形成环:从 `iParentId` 沿 `tModule.iParentId` 链向上回溯,每跳一次查一次 mapper;若某层 ID 等于路径 `{id}` → `BizException(40021, "父模块链构成环路")`;遍历深度上限 `50`,超限抛 `BizException(40021, "父模块链超过最大层级")` 防止脏数据死循环。 | |
| 72 | +4. **事务边界**:`update(...)` 上 `@Transactional(rollbackFor = Exception.class)`,包裹"目标查询 + 父链校验 + UPDATE"全部步骤。 | |
| 73 | +5. **空 body / 非 JSON**:交给 Spring + GlobalExceptionHandler,目前会落 `handleAny` 转 `code=50000`(同 MOD-001 已知行为,spec 未要求 fix)。 | |
| 74 | + | |
| 75 | +## 边界与约束 | |
| 76 | + | |
| 77 | +- **必填项缺失** → `40001` | |
| 78 | +- **`sDisplayType` 非枚举** → `40010` | |
| 79 | +- **iParentId 不合法(自指 / 不存在 / 环 / 超深)** → `40021` | |
| 80 | +- **目标 id 不存在或已软删** → `40400` | |
| 81 | +- **JWT 伪造** → `20001`(filter 短路) | |
| 82 | +- **JWT 缺失** → permitAll stub,不阻断(USR-004 后改 401) | |
| 83 | +- **不允许修改 `sProcedureName`**:DTO 直接不暴露该字段;即便前端误传,service 也不读;不需要单独错误码。 | |
| 84 | + | |
| 85 | +## 实现范围与边界抉择 | |
| 86 | + | |
| 87 | +1. **复用 MOD-001 工程脚手架**:无需新增 pom 依赖、Application、SecurityConfig 等;仅在 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` 上做增量。 | |
| 88 | +2. **SecurityConfig 路径调整**:把 MOD-001 的 `POST /api/mod/modules permitAll` 改为 `requestMatchers("/api/mod/**").permitAll()`,stub 范围一次性覆盖整个 MOD 模块的 4 个 REQ;注释保留 `// REQ-MOD-001 stub: see USR-004 follow-up`(路径更动,原 stub 锚点继续生效,无需新增锚点关键字)。 | |
| 89 | +3. **环检测策略**:选择"递归向上查 mapper"而非"DB 层 CTE",因为:① 单条业务路径,递归层数小(典型 < 5);② docs/04 § 3.4 禁循环 N+1 主要针对**列表场景**,单条更新接口的小循环不属于该约束;③ 避免引入 MyBatis-Plus 的 CTE 写法增加复杂度。 | |
| 90 | + | |
| 91 | +## 依赖的 schema 表 / 字段 | |
| 92 | + | |
| 93 | +写入表:`tModule` | |
| 94 | + | |
| 95 | +| 字段 | 用途 | 来源 | | |
| 96 | +|---|---|---| | |
| 97 | +| `iIncrement` | path id,定位行 | `@PathVariable` | | |
| 98 | +| `sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | `UpdateModuleDTO` | | |
| 99 | +| 其他字段 | 不更新 | — | | |
| 100 | + | |
| 101 | +依赖索引:`uk_procedure_name` 不冲突(不动该字段);`fk_module_parent` 在父链校验通过后由 INSERT/UPDATE 默认约束兜底。 | |
| 102 | + | |
| 103 | +## 依赖的接口 | |
| 104 | + | |
| 105 | +无(仅本 REQ 路径内部使用 MOD-001 已实现的 `ModuleMapper` 工具方法 + 新增父链查询)。 | |
| 106 | + | |
| 107 | +## 验收标准 | |
| 108 | + | |
| 109 | +### 单元测试(追加到 `ModuleServiceImplTest`) | |
| 110 | + | |
| 111 | +- [x] `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` — Mock `selectById` 返回非空、`existsActiveById` 返回 true(若有 parent);断言传入 `updateById` 的 entity:` iIncrement` 是路径 id;`sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 全部为 null(NOT_NULL 策略跳过);可改字段被透传。 | |
| 112 | +- [x] `updateWithTargetNotFound_throws40400` — Mock `selectById` 返回 null;不调 `updateById`。 | |
| 113 | +- [x] `updateWithInvalidDisplayType_throws40010` — DTO `sDisplayType="未知"`;不调 `updateById`。 | |
| 114 | +- [x] `updateWithSelfParentId_throws40021` — DTO `iParentId == path id`;错误信息含"自身"。 | |
| 115 | +- [x] `updateWithMissingParent_throws40021` — Mock `existsActiveById(parent) → false`;错误信息含"父模块不存在"。 | |
| 116 | +- [x] `updateWithCyclicParent_throws40021` — 构造 mapper 行为:`existsActiveById(parent)=true`;递归向上 `selectById(parent).getIParentId() == path id`;期望抛 40021,错误信息含"环路"。 | |
| 117 | +- [x] `updateWithBShowPermissionNull_setsFalseInEntity` — DTO `bShowPermission=null`;entity 字段为 `false`。 | |
| 118 | + | |
| 119 | +### 集成测试(追加到 `ModuleControllerIT`) | |
| 120 | + | |
| 121 | +- [x] `putValidBody_with_jwt_returns200_andUpdatesEditableFields` — 先 INSERT 一条原始行,再 PUT;查 DB:可改字段为新值,`sProcedureName` / `sCreatedBy` 保持原值。 | |
| 122 | +- [x] `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999`;`code=40400`。 | |
| 123 | +- [x] `putInvalidDisplayType_returns40010` — `code=40010`。 | |
| 124 | +- [x] `putSelfParent_returns40021` — body `iParentId == path id`;`code=40021`。 | |
| 125 | +- [x] `putCyclicParent_returns40021` — 准备数据:root → child;PUT root 把 `iParentId` 改成 child(构成环);`code=40021`。 | |
| 126 | +- [x] `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先 INSERT(通过 POST 接口或 JdbcTemplate),再无 token PUT;`sCreatedBy` 仍是原值(不被覆盖为 STUB_ADMIN)。 | |
| 127 | +- [x] `putTamperedJwt_returns20001` — `code=20001`。 | |
| 128 | + | |
| 129 | +### 工程验收 | |
| 130 | + | |
| 131 | +- [x] `cd backend && mvn -B test` 全绿(含 MOD-001 已有 26 用例 + 本 REQ 新增至少 7+7=14 用例,总 ≥ 40 用例) | |
| 132 | +- [x] SecurityConfig 路径规则更新后,MOD-001 已有 IT 仍 PASS(permitAll 范围扩大不收紧) | |
| 133 | +- [x] DB 中 `sProcedureName` 在更新前后字面相同(验证未被覆盖) | ... | ... |