Commit d4e9ca7bb4b6f08f22f6824d34e8b3f9003d22a9

Authored by zichun
1 parent 329a341f

docs(mod): review approval REQ-MOD-002

docs/08-模块任务管理.md
@@ -61,7 +61,7 @@ @@ -61,7 +61,7 @@
61 - MR: — 61 - MR: —
62 - 功能: 62 - 功能:
63 - [x] REQ-MOD-001 模块新增 63 - [x] REQ-MOD-001 模块新增
64 - - [ ] REQ-MOD-002 模块修改 64 + - [x] REQ-MOD-002 模块修改
65 - [ ] REQ-MOD-003 模块删除 65 - [ ] REQ-MOD-003 模块删除
66 - [ ] REQ-MOD-004 模块查询 66 - [ ] REQ-MOD-004 模块查询
67 67
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 § 零 技术栈外的依赖。