Commit 7429b0f9dd89a455167a4b783c175dc00eee8bf9

Authored by zichun
1 parent 49f72e9f

docs(mod): spec + plan REQ-MOD-002

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` 在更新前后字面相同(验证未被覆盖)