From d4e9ca7bb4b6f08f22f6824d34e8b3f9003d22a9 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 6 May 2026 17:52:36 +0800 Subject: [PATCH] docs(mod): review approval REQ-MOD-002 --- docs/08-模块任务管理.md | 2 +- docs/superpowers/plans/2026-05-06-REQ-MOD-002.md | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md | 31 +++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-05-06-REQ-MOD-002.md | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-06-REQ-MOD-002.md create mode 100644 docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md create mode 100644 docs/superpowers/specs/2026-05-06-REQ-MOD-002.md diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index 9e59147..131be57 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -61,7 +61,7 @@ - MR: — - 功能: - [x] REQ-MOD-001 模块新增 - - [ ] REQ-MOD-002 模块修改 + - [x] REQ-MOD-002 模块修改 - [ ] REQ-MOD-003 模块删除 - [ ] REQ-MOD-004 模块查询 diff --git a/docs/superpowers/plans/2026-05-06-REQ-MOD-002.md b/docs/superpowers/plans/2026-05-06-REQ-MOD-002.md new file mode 100644 index 0000000..20cc428 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-MOD-002.md @@ -0,0 +1,195 @@ +--- +req_id: REQ-MOD-002 +date: 2026-05-06 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-002.md +--- + +# REQ-MOD-002 模块修改 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `PUT /api/modules/{id}` 接口:复用 REQ-MOD-001 已落地的 entity / mapper / common / config 体系,仅追加 ModuleUpdateDTO + Service.update + Controller 方法 + 错误码常量。 + +**Architecture:** Service 层先校验目标存在性(按 PK + bDeleted=0 查),再校验 iParentId 自引用 / 父不存在 / 父是后代环路(沿父链 walk up,深度上限 5),最后 `updateById` 落库。环路检查走"自下而上"路径,O(depth) 复杂度。 + +**Tech Stack:** 沿用 REQ-MOD-001(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。 + +--- + +## Schema 改动 + +无(`tModule` schema 已由 V1 提供,本 REQ 不加列)。 + +## 文件变更清单 + +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_NOT_FOUND(40421, "模块不存在或已删除")` 和 `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` +- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` — PUT 入参(无 sProcedureName) +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `update(Integer id, ModuleUpdateDTO dto): ModuleVO` +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update` 方法(含父校验、环路检查、字段合并) +- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/{id}") ModuleVO update(...)` +- 创建: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` — DTO Bean Validation 单测 +- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 `update_*` 系列单元测试(mock ModuleMapper) +- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 `put_*` 系列集成测试 + +## 任务步骤 + +### Task 1: 追加错误码常量 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` +- Test: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`(追加断言) + +**API shape:** +- `MOD_NOT_FOUND(40421, "模块不存在或已删除")` +- `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` + +- [ ] **Step 1.1 写失败断言** + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加: + - `assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421);` + - `assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921);` + - 子会话确认 FAIL(编译错:枚举常量不存在) + +- [ ] **Step 1.2 追加枚举常量** + +- [ ] **Step 1.3 子会话确认 ApiResponseTest 全绿(5 个测试,第 5 个含新断言)** + +- [ ] **Step 1.4 提交** + - `git commit -m "feat(common): error codes for module update REQ-MOD-002"` + +--- + +### Task 2: ModuleUpdateDTO + 校验单测 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` +- Test: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` + +**API shape:** +- 字段(与 REQ-MOD-001 的 `ModuleCreateDTO` 相比剥除 `sProcedureName`;其余 7 个字段、注解、长度规则**完全一致**): + - `@NotBlank @Pattern(...) String sDisplayType` + - `@NotBlank @Size(max=50) String sModuleType` + - `@NotBlank @Size(max=50) String sManageDeptEn` + - `Boolean bShowPermission`(可空) + - `@NotBlank @Size(max=100) String sModuleNameZh` + - `Integer iParentId`(可空) + - `@Min(0) Integer iSortOrder`(可空) + +- [ ] **Step 2.1 写失败测试(4 个)** + - `ModuleUpdateDTOValidationTest#allValidFields_yieldsNoViolations` + - `ModuleUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(5 个 @NotBlank) + - `ModuleUpdateDTOValidationTest#invalidDisplayTypeEnum_yieldsViolation` + - `ModuleUpdateDTOValidationTest#negativeSortOrder_yieldsViolation` + - 子会话: FAIL(DTO 不存在) + +- [ ] **Step 2.2 实现 ModuleUpdateDTO** + - 子会话: PASS + +- [ ] **Step 2.3 提交** + - `git commit -m "feat(mod): module update DTO REQ-MOD-002"` + +--- + +### Task 3: ModuleService.update — 业务逻辑(mock 单元测试) + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`(追加方法签名) +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`(实现 + 私有 helper) +- Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 8 个测试) + +**API shape:** +- `interface ModuleService` 追加:`ModuleVO update(Integer id, ModuleUpdateDTO dto)` +- 实现步骤(写在 plan 锁定): + 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → 抛 `BizException(MOD_NOT_FOUND)` + 2. iParentId 校验(仅当 `dto.iParentId != null`): + - 等于 `id` → `BizException(MOD_PARENT_LOOP)` + - `parent = moduleMapper.selectById(dto.iParentId)`;`parent == null || parent.bDeleted` → `BizException(MOD_PARENT_NOT_FOUND)` + - **环路检查**(沿父链 walk up,从 `dto.iParentId` 出发,最多 5 层): + ``` + cur = parent; depth = 1 + while cur.iParentId != null && depth <= 5: + if cur.iParentId == id: throw MOD_PARENT_LOOP + cur = moduleMapper.selectById(cur.iParentId) + if cur == null or cur.bDeleted: break // 链断在已删除节点,视为非环 + depth += 1 + ``` + depth 超 5 仍未结束 → 视为深度违规,但 docs/03 业务注记说"深度上限 5"是预期不变量;本期不强制拦截更深,仅环路检查。 + 3. 字段合并到 `target`: + - 必填字段(5 个 @NotBlank + sDisplayType):直接覆盖(dto 为 null 不可能,validation 已挡) + - `bShowPermission`:dto 非 null 覆盖;null 保留 `target.bShowPermission` + - `iParentId`:dto 中存在该 key 即覆盖(含 null 设根)。**实现细节**:DTO 用 Integer,区分"未传"和"显式传 null"在 Spring MVC 反序列化层不区分(都是 Integer null)。本 REQ 取语义"传 null = 设根","key 缺失 = 设根"等价。 + - `iSortOrder`:dto 非 null 覆盖;null 保留 + - `sProcedureName` / `iIncrement` / `tCreateDate` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`:**完全不动**(沿用 target 上的原值) + 4. `moduleMapper.updateById(target)` + 5. `return ModuleVO.from(target)` + +- 标 `@Transactional(rollbackFor = Exception.class)` + +- [ ] **Step 3.1 写失败测试(8 个)** + - `update_targetNotFound_throws40421`:`selectById(id)` → null + - `update_targetSoftDeleted_throws40421`:`selectById(id).bDeleted=true` + - `update_parentSelfReference_throws40921`:`dto.iParentId == id` + - `update_parentNotFound_throws40411`:`selectById(parentId)` → null + - `update_parentIsDescendant_throws40921`:构造 grandparent(id)→parent→child 链,dto.iParentId=child.id + - `update_full_returnsVOWithUpdatedFields`:mock target 与 update 路径,断言传给 `updateById` 的 entity: + - 已修改字段:sDisplayType / sModuleType / sManageDeptEn / sModuleNameZh / iParentId / iSortOrder / bShowPermission + - 保持原值:sProcedureName / iIncrement / tCreateDate / sCreatedBy / bDeleted + - `update_partialNullFields_keepsOriginalValues`:dto 中 bShowPermission=null + iSortOrder=null,断言落库 entity 的对应字段保留 target 原值 + - `update_clearParent_setsParentToNull`:dto.iParentId=null,target.iParentId 原本是 7;断言 entity.iParentId == null + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor` 捕获 `updateById` 实参 + - 子会话: FAIL(方法不存在) + +- [ ] **Step 3.2 实现 ModuleService 接口签名 + ModuleServiceImpl.update** + +- [ ] **Step 3.3 子会话确认全部 mock 单测通过** + - 全量 `mvn -B test -Dtest=ModuleServiceImplTest` 应绿(含 REQ-MOD-001 的 6 个 + 新增 8 个 = 14 个) + +- [ ] **Step 3.4 提交** + - `git commit -m "feat(mod): update module service REQ-MOD-002"` + +--- + +### Task 4: ModuleController PUT 端点 + 端到端 IT + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` +- Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`(追加 8 个集成用例) + +**API shape:** +- 类上保留 `@RequestMapping("/api/modules")` +- 新方法: + ``` + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Integer id, @Valid @RequestBody ModuleUpdateDTO dto) + ``` +- 注释:`// REQ-MOD-002 模块修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")` + +- [ ] **Step 4.1 写失败测试(8 个)** + - `put_validUpdate_returns200`:先用 ModuleMapper 直接 insert 一条,再 PUT 改若干字段,断言响应 + DB 字段 + - `put_setParentToNull_clearsParent`:先建 parent + child(iParentId=parent.id),PUT child 把 iParentId=null,断言 DB 中 child.iParentId IS NULL + - `put_targetNotFound_returns40421`:`PUT /api/modules/999999` + - `put_parentNotFound_returns40411`:`iParentId=999999` + - `put_parentSelfRef_returns40921`:`PUT /api/modules/{id}` body `iParentId={id}` + - `put_parentIsDescendant_returns40921`:建 grandparent→parent→child 三层;PUT grandparent 把 iParentId=child.id + - `put_missingRequired_returns40010`:缺 sModuleNameZh + - `put_ignoresProcedureNameField_doesNotChange`:body 含 `"sProcedureName":"hijack"`,断言 DB 中 sProcedureName 仍为原值 + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据 + - 子会话: FAIL(端点不存在) + +- [ ] **Step 4.2 实现 PUT 端点** + - 子会话: PASS + +- [ ] **Step 4.3 跑全量 backend 测试** + - `cd backend && mvn -B test` + - 期望累计 22 + 4(DTO valid) + 8(service update) + 8(controller put) = 42 个,全绿 + +- [ ] **Step 4.4 提交** + - `git commit -m "feat(mod): PUT /api/modules/{id} controller REQ-MOD-002"` + +--- + +## 提交计划 + +- `feat(common): error codes for module update REQ-MOD-002`(覆盖 Task 1) +- `feat(mod): module update DTO REQ-MOD-002`(覆盖 Task 2) +- `feat(mod): update module service REQ-MOD-002`(覆盖 Task 3) +- `feat(mod): PUT /api/modules/{id} controller REQ-MOD-002`(覆盖 Task 4) diff --git a/docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md b/docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md new file mode 100644 index 0000000..abb6201 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-MOD-002.md @@ -0,0 +1,31 @@ +--- +req_id: REQ-MOD-002 +date: 2026-05-06 +round: 1 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-MOD-002 — round 1 + +## 结论 +approve + +## Must-fix +(无) + +## Nice-to-have + +- 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 方法本地。 +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:207 — spec § 验收 #2「正向 — 设置父模块到合法 sibling」只覆盖了 setParentToNull,没有覆盖"把 iParentId 改到另一个未删除模块"的正向写入路径。建议追加一个 IT 用例。 +- 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 直接退出循环的非环场景。 +- 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 注脚说明。 +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:597 — `when(moduleMapper.selectById(999999)).thenReturn(null)` 冗余(Mockito 默认就是 null),可删除。 + +## 反例 / 测试覆盖缺口 + +1. spec § 验收 #2「正向设置父模块到 sibling」在 IT 与单元两层都缺成功路径用例;只覆盖了"清空父"和各种 parent 校验失败用例,未直接验证 iParentId 从 null/某值改到另一个有效模块后 DB 实际写入了新父 id 的主线路径。 +2. spec § 验收 #6「目标已软删除」仅在单元层覆盖(`update_targetSoftDeleted_throws40421`),IT 缺一个"先 update bDeleted=1 再 PUT 返回 40421"用例。 +3. spec § 验收 #8(枚举非法 `sDisplayType="X"`)/ #9(`sModuleType=51 字符`)只在 `ModuleUpdateDTOValidationTest` 单元层验证,未在 IT 走一遍 PUT 端到端断言 40010。 +4. 环路检查的"4-5 层深度边界"和"父链中存在已软删除节点导致提前 break 的非环场景"未单独覆盖;本期数据量低可接受。 +5. `FieldStrategy.IGNORED` 是 entity 全局变更,对未来其他 service 走 partial `updateById` 路径会埋雷;建议要么文档化要么把 NULL 写入收敛到本地策略。 +6. 未发现硬编码凭据、未跨模块改动、未引入技术栈外组件、commit 全部带 `REQ-MOD-002` 标签、响应不回显堆栈——全部合规。错误码(40010/40411/40421/40921)与 docs/05 / spec 一致。环路检查实现 walk-up 沿父链最多 5 层 + 已删节点 break,逻辑正确。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-MOD-002.md b/docs/superpowers/specs/2026-05-06-REQ-MOD-002.md new file mode 100644 index 0000000..5c5c293 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-MOD-002.md @@ -0,0 +1,186 @@ +--- +req_id: REQ-MOD-002 +date: 2026-05-06 +module: module_mod +--- + +# Spec: REQ-MOD-002 — 模块修改 + +## 目标 + +实现后端 `PUT /api/modules/{id}` 接口:在不破坏唯一性 / 树结构完整性的前提下,更新已有模块的可编辑字段,返回最新模块 VO。 + +## 输入 / 触发 + +**接口**:`PUT /api/modules/{id}`,Content-Type `application/json`。`{id}` = `tModule.iIncrement`。 + +**Request body**(`ModuleUpdateDTO`)字段——与 REQ-MOD-001 输入相比**剥除 `sProcedureName`**(不可改,contract 约束);其余 7 个业务字段含义和校验规则保持一致: + +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | +|---|---|---|---|---| +| `sDisplayType` | String | 是 | 枚举:`手机端` / `前端业务` / `系统配置` / `接口` | `tModule.sDisplayType` | +| `sModuleType` | String | 是 | 长度 1-50 | `tModule.sModuleType` | +| `sManageDeptEn` | String | 是 | 长度 1-50 | `tModule.sManageDeptEn` | +| `bShowPermission` | Boolean | 否 | 默认保持原值;显式传 `null` 视为不变 | `tModule.bShowPermission` | +| `sModuleNameZh` | String | 是 | 长度 1-100 | `tModule.sModuleNameZh` | +| `iParentId` | Integer | 否 | 可空(设为根模块);非空必须存在且未软删除;不能等于 `{id}` 自身或其后代 | `tModule.iParentId` | +| `iSortOrder` | Integer | 否 | 默认保持原值;非负整数 | `tModule.iSortOrder` | + +> **`sProcedureName` 不在 DTO 中**:Jackson 反序列化时若客户端误传将被忽略(`@JsonIgnoreProperties(ignoreUnknown = true)` 由 Jackson 默认行为兜底;不抛错)。前端 UI 应把该字段渲染为只读。 +> +> **PUT 语义**:本接口采用全量替换语义。请求体中显式存在的字段均落库;若未提供(JSON 中 key 缺失或值为 `null`),按字段下方"必填"列:必填字段缺失 → `40010`;可选字段缺失 → 保持数据库原值。 + +**鉴权**:契约要求 `Authorization: Bearer ` + 权限码 `MOD:UPDATE`。本 REQ 沿用 REQ-MOD-001 的 SecurityConfig permitAll 占位(REQ-USR-004 后回头收紧);Controller 写注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。 + +## 输出 / 结果 + +**HTTP 200,响应体**(统一响应格式): + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "iIncrement": 12, + "sDisplayType": "前端业务", + "sProcedureName": "sp_audit_user_module", + "sModuleType": "USR", + "sManageDeptEn": "IT", + "bShowPermission": true, + "sModuleNameZh": "用户管理(修订)", + "iParentId": 3, + "iSortOrder": 5, + "tCreateDate": "2026-05-06T10:30:00", + "bDeleted": false + }, + "timestamp": 1746528600000 +} +``` + +VO 复用 REQ-MOD-001 的 `ModuleVO`(11 个字段)。 + +## 业务规则 + +1. **目标模块必须存在且未软删除**:`SELECT ... WHERE iIncrement = {id} AND bDeleted = 0`。不存在或已删 → `40421`。 +2. **`sProcedureName` 不可改**:DTO 不接受该字段;后端读取目标记录后保留原 `sProcedureName` 不变。 +3. **`iParentId` 自引用校验**: + - 若 `iParentId` 等于路径参数 `{id}`(自引用)→ `40921`。 + - 若 `iParentId` 在 `tModule` 中不存在或已软删除 → `40411`。 + - 若 `iParentId` 是 `{id}` 的后代(沿 `iParentId` 链向下走,深度上限 5 层与 docs/03 § tModule 业务注记一致)→ `40921`。 +4. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。 +5. **`bShowPermission` / `iSortOrder` 部分更新**:DTO 中为 `null` → 保持原值;显式传值 → 覆盖。 +6. **审计**:本 REQ 暂不维护"最近修改时间"和"修改人"列(schema 未规划相关字段,docs/03 也未要求)。后续若需,按 V_n migration 加列同步更新 docs/03。 +7. **多租户字段不写入**:与 REQ-MOD-001 一致,本接口不动 `sBrandsId / sSubsidiaryId`。 + +## 边界与约束 + +### 鉴权策略(本 REQ 限定) + +沿用 REQ-MOD-001:SecurityConfig permitAll;Controller 上写说明性注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")`。 + +### 事务 + +- Service 方法标 `@Transactional(rollbackFor = Exception.class)`。读取目标模块 → 校验 → 更新 全在同一事务。 +- 父模块校验 + 后代环路检查需多次 `selectById`,事务内可能产生几次小查询;本期数据量低,不做缓存优化。 + +### 并发 + +- 用 `moduleMapper.updateById(entity)` 走 PK 更新;不引入乐观锁版本号(schema 没规划 `version` 列)。 +- 并发同时更新同一模块时遵循"后写覆盖"语义,可接受。需要更强一致性时另开 REQ。 + +### 性能 + +- 后代环路检查用迭代 BFS(队列),每次查 `selectList(eq("iParentId", ...))` 拿子节点;深度上限 5 层 + 单层节点数受限于业务,不做递归 SQL。 + +### 错误码映射(与 docs/05 对齐) + +| 场景 | 错误码 | +|---|---| +| 必填字段缺失 / 类型错误 / 长度超限 / 枚举非法 | `40010` | +| `{id}` 模块不存在或已软删除 | `40421` | +| `iParentId` 指向不存在 / 已删模块 | `40411` | +| `iParentId == {id}` 或为 `{id}` 的后代 | `40921` | +| 服务端兜底 | `50000` | + +> docs/05 列出的 `40911`(sProcedureName 冲突)在本实现里不会触发(DTO 不接受 sProcedureName);保留契约文档不变即可。 +> 新增错误码 `40921` 需补到 `ErrorCode` 枚举(命名 `MOD_PARENT_LOOP`);`40421` 命名 `MOD_NOT_FOUND`。 + +## 依赖的 schema 表 / 字段 + +**写表**:`tModule`(详见 docs/03 § tModule) + +| 字段 | 行为 | +|---|---| +| `iIncrement` | 路径参数 `{id}` 定位行,**不修改** | +| `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` | **不修改** | +| `sDisplayType` | 入参覆盖 | +| `sProcedureName` | **不修改**(保留原值) | +| `sModuleType` | 入参覆盖 | +| `sManageDeptEn` | 入参覆盖 | +| `bShowPermission` | 入参非 null 覆盖;null 保留 | +| `sModuleNameZh` | 入参覆盖 | +| `iParentId` | 入参覆盖(含 null 设根) | +| `iSortOrder` | 入参非 null 覆盖;null 保留 | +| `bDeleted` / `tDeletedDate` / `sDeletedBy` | **不修改** | + +**索引利用**: +- 主键定位 `{id}` +- `idx_parent` / `fk_module_parent`:iParentId 校验时按父链 / 子链查询 + +**外键**:`fk_module_parent` 仍兜底;应用层环路检查在写入前显式拦截。 + +## 依赖的接口 + +无(本接口独立工作;与 REQ-MOD-001 并列同模块同 schema)。 + +## 验收标准 + +### 功能正确性 + +1. **正向 — 全量更新非父字段**:传入合法的 7 个字段(不含 `iParentId` 自引用),返回 200 + 最新 VO;DB 中查询新值与入参一致;`sProcedureName` / `tCreateDate` 与原值相同。 +2. **正向 — 设置父模块**:先建 root + child,再 `PUT /api/modules/{child_id}` 把 `iParentId` 改到另一个 sibling;返回 200,DB 中 `iParentId` 更新成功。 +3. **正向 — 清空父模块(设为根)**:`PUT` 时显式传 `"iParentId": null`,DB 中 `iParentId` 变 NULL。 +4. **正向 — 部分字段保留原值**:DTO 中 `bShowPermission` / `iSortOrder` 传 null,DB 中保留原值。 +5. **目标不存在**:`PUT /api/modules/999999`,返回 200 + `code=40421`。 +6. **目标已软删除**:先把模块 `bDeleted` 置 1(直接 DB UPDATE 模拟),再 `PUT`,返回 `40421`。 +7. **必填缺失**:DTO 缺 `sModuleNameZh`,返回 `40010`。 +8. **枚举非法**:`sDisplayType="X"`,返回 `40010`。 +9. **长度超限**:`sModuleType` = 51 字符,返回 `40010`。 +10. **iParentId 自引用**:`PUT /api/modules/{id}` 把 `iParentId` 设为 `{id}` 本身,返回 `40921`。 +11. **iParentId 不存在**:`PUT` 时 `iParentId=999999`,返回 `40411`。 +12. **iParentId 是后代**:祖父→父→子三层结构,`PUT` 祖父把 `iParentId` 设为子的 id,返回 `40921`。 +13. **sProcedureName 字段被忽略**:客户端误传 `sProcedureName="other"`,DB 中该字段保持原值。 + +### 接口契约一致性 + +- 响应格式严格符合 `{code, message, data, timestamp}`(docs/05 § 全局约定)。 +- 错误码段位与 docs/05 一致:`40010` / `40411` / `40421` / `40921` / `50000`。 +- 异常堆栈不出现在响应里。 + +### 测试覆盖 + +- **单元测试** `ModuleServiceImplTest`(继续 mock ModuleMapper): + - update_targetNotFound_throws40421 + - update_targetSoftDeleted_throws40421 + - update_parentSelfReference_throws40921 + - update_parentNotFound_throws40411 + - update_parentIsDescendant_throws40921 + - update_full_returnsVOWithUpdatedFields(断言传给 mapper.updateById 的 entity 字段值,包括 sProcedureName 保留) + - update_partialNullFields_keepsOriginalValues + - update_clearParent_setsParentToNull + +- **集成测试** `ModuleControllerIT` 追加(`@Transactional` 自动回滚;用 ModuleMapper 直接预置数据): + - put_validUpdate_returns200 + - put_setParentToNull_clearsParent + - put_targetNotFound_returns40421 + - put_parentNotFound_returns40411 + - put_parentSelfRef_returns40921 + - put_parentIsDescendant_returns40921 + - put_missingRequired_returns40010 + - put_ignoresProcedureNameField_doesNotChange + +### 代码与文档 + +- `// REQ-MOD-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode 枚举常量上。 +- 提交按 `feat(mod): REQ-MOD-002` 规范,每 Task 一个 commit。 +- 不引入 docs/04 § 零 技术栈外的依赖。 -- libgit2 0.22.2