From 7429b0f9dd89a455167a4b783c175dc00eee8bf9 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 29 Apr 2026 17:44:47 +0800 Subject: [PATCH] docs(mod): spec + plan REQ-MOD-002 --- docs/superpowers/plans/2026-04-29-REQ-MOD-002.md | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-04-29-REQ-MOD-002.md | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 0 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-29-REQ-MOD-002.md create mode 100644 docs/superpowers/specs/2026-04-29-REQ-MOD-002.md diff --git a/docs/superpowers/plans/2026-04-29-REQ-MOD-002.md b/docs/superpowers/plans/2026-04-29-REQ-MOD-002.md new file mode 100644 index 0000000..4e041ea --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-REQ-MOD-002.md @@ -0,0 +1,222 @@ +--- +req_id: REQ-MOD-002 +date: 2026-04-29 +spec_ref: docs/superpowers/specs/2026-04-29-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:** 在 MOD-001 已有工程基础上增量实现 `PUT /api/mod/modules/{id}`,更新 7 个可编辑字段,保留 `sProcedureName` / `sCreatedBy` / 标准列;含目标存在性、枚举、父链合法性(自指 / 不存在 / 环)四类校验。 + +**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。 + +**Tech Stack:** Spring Boot 3.3.5 / MyBatis-Plus / Spring Security(已有);JUnit 5 + Mockito + TestRestTemplate(已有)。 + +--- + +## Schema 改动 + +无(`tModule` schema 已满足;本 REQ 仅 UPDATE 操作)。 + +## 文件变更清单 + +### 新增 + +- `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java` — 入参 DTO(无 `sProcedureName` 字段) + +### 修改 + +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — `requestMatchers(POST, "/api/mod/modules")` → `requestMatchers("/api/mod/**")`,stub 注释保持 +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `Integer selectParentIdById(Integer id)` 注解 SELECT 方法(仅查 iParentId 列,不取整行) +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `Integer update(Integer id, UpdateModuleDTO dto)` 方法 +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update(...)`:目标存在性 → 枚举 → iParentId 三重校验 → mapper.updateById +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/modules/{id}")` 端点 +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 7 个 update 用例 +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 PUT IT 用例 +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 个 `selectParentIdById` 用例 + +## 任务步骤 + +> 全局约束:每 commit 形如 `(mod): REQ-MOD-002`;测试派发到子会话执行;现有 26 用例全程保持绿。 + +### Task 1: SecurityConfig 路径白名单扩范围 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java:25` + +**API shape:** +- 改一行:`requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll()` → `requestMatchers("/api/mod/**").permitAll()` +- 注释保留 `// REQ-MOD-001 stub: see USR-004 follow-up`(语义不变,覆盖范围扩大) + +- [ ] **Step 1: 修改 SecurityConfig** + - 单行 Edit;不动任何其他类 + +- [ ] **Step 2: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=ModuleControllerIT` + - 期望:现有 7 用例全绿(permitAll 范围扩大不收紧已有路径) + +- [ ] **Step 3: Commit** + - `git commit -m "refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002"` + +### Task 2: ModuleMapper 追加父 ID 查询 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` +- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` + +**API shape:** +- `@Select("SELECT iParentId FROM tModule WHERE iIncrement = #{id} AND bDeleted = 0")` `Integer selectParentIdById(@Param("id") Integer id)` + - 命中行的 `iParentId` 可能为 NULL(根模块)→ 返回 null + - 未命中(不存在 / 已软删)→ 返回 null(与"根模块"在调用方语义不同,需要调用方明确校验存在性) + +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#selectParentIdById_returnsNullForRootOrMissing_andValueForChild`** + - 准备 3 行:root(iParentId=NULL)、child(iParentId=root)、deleted(iParentId=root, bDeleted=1) + - 断言:`selectParentIdById(root.id) == null`;`selectParentIdById(child.id) == root.id`;`selectParentIdById(deleted.id) == null`;`selectParentIdById(99999999) == null` + +- [ ] **Step 2: 实现 mapper 方法** + +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` + +- [ ] **Step 4: Commit** + - `git commit -m "feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002"` + +### Task 3: UpdateModuleDTO + Service.update 合法路径 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java` +- 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` +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` + +**API shape:** +- `UpdateModuleDTO` 字段(带 `@JsonProperty` 锁定 JSON 名 + Bean Validation): + - `@NotBlank String sDisplayType` + - `@NotBlank @Size(max=50) String sModuleType` + - `@NotBlank @Size(max=50) String sManageDeptEn` + - `Boolean bShowPermission`(可空) + - `@NotBlank @Size(max=100) String sModuleNameZh` + - `Integer iParentId`(可空) + - `Integer iSortOrder`(可空) + - **不含 `sProcedureName`** +- `ModuleService#update(Integer id, UpdateModuleDTO dto) : Integer` +- `ModuleServiceImpl#update`: + 1. `Module original = moduleMapper.selectById(id)`;若 null 或 `original.getBDeleted()==true` → `BizException(40400, "模块不存在或已删除")` + 2. 枚举校验同 MOD-001 → 40010 + 3. `iParentId` 校验(4 类)(在本 task 不全实现,仅留 hook,详见 Task 4):本 task 暂只对 null/合法路径走通;非 null 时调 `existsActiveById` 但暂不做自指/环检测,留 Task 4 加。 + 4. 构造 `Module entity`:仅 set `iIncrement` 和可改 7 字段(其余 null);`bShowPermission` null → false + 5. `moduleMapper.updateById(entity)` 返回 `id` + +- [ ] **Step 1: 写失败测试** + - 在 `ModuleServiceImplTest` 追加 3 用例: + - `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` + - `updateWithTargetNotFound_throws40400` + - `updateWithBShowPermissionNull_setsFalseInEntity` + - mock 准备:`moduleMapper.selectById(id)` 返回 stub Module(含原 sProcedureName / sCreatedBy);`moduleMapper.updateById(any(Module.class))` 返回 1 + - ArgumentCaptor 抓传给 `updateById` 的 entity,断言:`iIncrement` 是路径 id;`sProcedureName == null`;`sCreatedBy == null`;`tCreateDate == null`;`sBrandsId == null`;`sSubsidiaryId == null`;可改字段被透传 + - 子会话先跑 → FAIL(方法不存在) + +- [ ] **Step 2: 实现 DTO + Service** + - DTO 与 CreateModuleDTO 平行结构;Service 实现仅覆盖 Task 3 三个用例所需逻辑 + +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` + - 期望:6 (MOD-001) + 3 (本 task) = 9 用例全绿 + +- [ ] **Step 4: Commit** + - `git commit -m "feat(mod): module update dto + service happy path REQ-MOD-002"` + +### Task 4: Service 校验分支(枚举 + iParentId 三类) + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` + +**API shape:** 不变(仅补 update 方法内部校验逻辑) + +**校验顺序**(service 实现): + +1. 目标存在 / 枚举(已在 Task 3 实现) +2. 若 `iParentId != null`: + a. `iParentId.equals(id)` → `BizException(40021, "父模块不能指向自身")` + b. `!moduleMapper.existsActiveById(iParentId)` → `BizException(40021, "父模块不存在或已删除")` + 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, "父模块链超过最大层级")` + +- [ ] **Step 1: 写失败测试** + - 在 `ModuleServiceImplTest` 追加 4 用例: + - `updateWithInvalidDisplayType_throws40010` + - `updateWithSelfParentId_throws40021`(msg contains "自身") + - `updateWithMissingParent_throws40021`(msg contains "父模块不存在") + - `updateWithCyclicParent_throws40021`(msg contains "环路";mock 编排:`existsActiveById(parent)=true`;`selectParentIdById(parent) = id`) + - 子会话先跑 → 4 用例 FAIL + +- [ ] **Step 2: 实现校验分支** + - 严格按上述 a/b/c 顺序,**a 在 b 之前**(自指应当先于存在性,否则用户传自身 id 在父表里又恰好不存在时报错信息会误导) + +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` + - 期望:6 + 3 + 4 = 13 用例全绿 + +- [ ] **Step 4: Commit** + - `git commit -m "feat(mod): module update parent validation REQ-MOD-002"` + +### Task 5: ModuleController PUT + IT 正常路径 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` + +**API shape:** +- `@PutMapping("/modules/{id}") public Result> update(@PathVariable Integer id, @Valid @RequestBody UpdateModuleDTO dto)` +- 返回 `Result.ok(Map.of("iIncrement", moduleService.update(id, dto)))` + +- [ ] **Step 1: 写失败测试 `ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields`** + - 步骤:① 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"`(保留) + +- [ ] **Step 2: 实现 controller PUT** + - 与 POST 同结构 + +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest='ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields'` + +- [ ] **Step 4: Commit** + - `git commit -m "feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002"` + +### Task 6: IT 异常路径补全 + 全量回归 + +**Files:** +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` + +**API shape:** 不新增(覆盖 PUT 接口 6 条异常路径) + +- [ ] **Step 1: 在 IT 中追加 6 个用例** + - `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999` body 合法 → `code=40400` + - `putInvalidDisplayType_returns40010` — `sDisplayType="火星"` → `code=40010` + - `putSelfParent_returns40021` — body `iParentId == path id` → `code=40021` + - `putCyclicParent_returns40021` — 准备:先插 root,再插 child(parent=root);PUT root 把 `iParentId=child.id` → `code=40021` + - `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先插原行(sCreatedBy="ORIG_USER"),无 token PUT;期望 `code=0`,DB 中 sCreatedBy 仍为 "ORIG_USER"(不被覆盖为 STUB_ADMIN) + - `putTamperedJwt_returns20001` — Authorization 头 `Bearer not.a.real.jwt` → `code=20001` + - 6 个用例先跑 → 期望 FAIL(部分分支需 Task 4/5 已经覆盖;这里主要补 IT 端到端) + +- [ ] **Step 2: 让测试通过** + - service / controller / config 已实现;本 task 主要排查 RestTemplate 行为(4xx 是否抛、URL 拼接、JSON parse);不应当为让测试通过新增业务分支 + +- [ ] **Step 3: 子会话跑全量回归** + - 命令:`cd backend && mvn -B test` + - 期望:MOD-001 26 用例 + MOD-002 新增 1(mapperIT) + 7(serviceTest) + 7(controllerIT) = 41 用例全绿 + +- [ ] **Step 4: Commit** + - `git commit -m "test(mod): module update integration coverage REQ-MOD-002"` + +## 提交计划 + +| commit | 覆盖 | +|---|---| +| `refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002` | Task 1 | +| `feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002` | Task 2 | +| `feat(mod): module update dto + service happy path REQ-MOD-002` | Task 3 | +| `feat(mod): module update parent validation REQ-MOD-002` | Task 4 | +| `feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002` | Task 5 | +| `test(mod): module update integration coverage REQ-MOD-002` | Task 6 | diff --git a/docs/superpowers/specs/2026-04-29-REQ-MOD-002.md b/docs/superpowers/specs/2026-04-29-REQ-MOD-002.md new file mode 100644 index 0000000..555f140 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-REQ-MOD-002.md @@ -0,0 +1,133 @@ +--- +req_id: REQ-MOD-002 +date: 2026-04-29 +module: module_mod +--- + +# Spec: REQ-MOD-002 — 模块修改 + +## 目标 + +在不破坏唯一性的前提下,更新已有模块的可编辑字段:`sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder`。`sProcedureName`、`sCreatedBy`、`tCreateDate`、`sBrandsId` / `sSubsidiaryId`、软删除字段一律保留原值。 + +## 输入 / 触发 + +### HTTP 接口(来自 docs/05 § REQ-MOD-002) + +- Method / Path: `PUT /api/mod/modules/{id}`(`{id}` = `tModule.iIncrement`) +- Auth: 必需(JWT Bearer) +- Permission: 仅超级管理员(**沿用 MOD-001 的 stub:SecurityConfig 路径范围扩展为 `/api/mod/**` permitAll,USR-004 完成后统一改 `hasAuthority('SUPER_ADMIN')`**) + +### 请求 DTO `UpdateModuleDTO` + +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | +|---|---|---|---|---| +| `sDisplayType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[手机端, 前端业务, 系统配置, 接口]` 内;非法 → `40010` | +| `sModuleType` | `String` | 是 | `@NotBlank @Size(max=50)` | 自由文本 | +| `sManageDeptEn` | `String` | 是 | `@NotBlank @Size(max=50)` | — | +| `bShowPermission` | `Boolean` | 否 | — | 缺省视为 `false`(写 0) | +| `sModuleNameZh` | `String` | 是 | `@NotBlank @Size(max=100)` | — | +| `iParentId` | `Integer` | 否 | — | 不为 null 时:① 不能等于路径 `{id}`(自指);② 必须命中存在且 `bDeleted=0` 的记录;③ 沿父链遍历不能在路径中出现 `{id}`(环检测)。三种违反统一 → `40021` | +| `iSortOrder` | `Integer` | 否 | — | 缺省 `0` | + +> **`sProcedureName` 显式从 DTO 中剔除**——API 契约声明该字段不可改;前端表单仍可显示原值(REQ 卡列为必填仅是前端 UX 约束),但 PUT body 中即便传了也会被 Jackson 丢弃,不进入 service。这一处与 REQ 卡输入表的差异是**有意的**:以 docs/05 API 契约为准。 + +### 鉴权与上下文 + +同 MOD-001:JWT Filter 解析 token 写 `principal=sUserNo`;本 REQ 走 `permitAll` stub,不强制要求 token;伪造 token 仍被 filter 短路返回 `code=20001`。`sCreatedBy` 在更新时**不修改**,无论是否携带 token。 + +## 输出 / 结果 + +### 成功响应 + +```json +{ + "code": 0, + "msg": "ok", + "data": { "iIncrement": 123 } +} +``` + +### 持久化效果 + +`UPDATE tModule SET <可编辑列> WHERE iIncrement = {id}`。 + +| 字段 | 更新策略 | +|---|---| +| `sDisplayType` / `sModuleType` / `sManageDeptEn` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | +| `bShowPermission` | DTO null → `false`,否则 DTO 值 | +| `sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `bDeleted` / `tDeletedDate` / `sDeletedBy` / `sId` | **不更新**(entity 上对应字段保持 null,依赖 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL` 跳过 null 字段) | + +> 实施细节:MyBatis-Plus 默认全局 `update-strategy: NOT_NULL`,但需在 entity 字段上不显式标注其他 strategy。`bShowPermission` 因 DTO null 时要写 false,故 service 层先把 DTO null 展开为 false 再赋值给 entity;其余 null 字段保持 null。 + +## 业务规则 + +1. **目标存在性**:先 `SELECT * FROM tModule WHERE iIncrement = {id} AND bDeleted = 0`;找不到 → `BizException(40400, "模块不存在或已删除")`。 +2. **枚举校验**:`sDisplayType` 在 `DISPLAY_TYPES` 内(复用 `ModuleServiceImpl.DISPLAY_TYPES`);非法 → `BizException(40010, "显示类型枚举不合法")`。 + > docs/05 § MOD-002 错误码表只列了 40001/40021/40400,未单列 40010。本实现选择复用 MOD-001 已建立的 40010 语义(保持同一字段两个接口的错误码一致)。这条偏离已记入 spec,后续若需统一可在 docs/05 补一行。 +3. **iParentId 校验**(`iParentId != null` 时): + - 自指 (`iParentId == id`) → `BizException(40021, "父模块不能指向自身")` + - 不存在 / 已软删 (`!moduleMapper.existsActiveById(iParentId)`) → `BizException(40021, "父模块不存在或已删除")` + - 形成环:从 `iParentId` 沿 `tModule.iParentId` 链向上回溯,每跳一次查一次 mapper;若某层 ID 等于路径 `{id}` → `BizException(40021, "父模块链构成环路")`;遍历深度上限 `50`,超限抛 `BizException(40021, "父模块链超过最大层级")` 防止脏数据死循环。 +4. **事务边界**:`update(...)` 上 `@Transactional(rollbackFor = Exception.class)`,包裹"目标查询 + 父链校验 + UPDATE"全部步骤。 +5. **空 body / 非 JSON**:交给 Spring + GlobalExceptionHandler,目前会落 `handleAny` 转 `code=50000`(同 MOD-001 已知行为,spec 未要求 fix)。 + +## 边界与约束 + +- **必填项缺失** → `40001` +- **`sDisplayType` 非枚举** → `40010` +- **iParentId 不合法(自指 / 不存在 / 环 / 超深)** → `40021` +- **目标 id 不存在或已软删** → `40400` +- **JWT 伪造** → `20001`(filter 短路) +- **JWT 缺失** → permitAll stub,不阻断(USR-004 后改 401) +- **不允许修改 `sProcedureName`**:DTO 直接不暴露该字段;即便前端误传,service 也不读;不需要单独错误码。 + +## 实现范围与边界抉择 + +1. **复用 MOD-001 工程脚手架**:无需新增 pom 依赖、Application、SecurityConfig 等;仅在 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` 上做增量。 +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 锚点继续生效,无需新增锚点关键字)。 +3. **环检测策略**:选择"递归向上查 mapper"而非"DB 层 CTE",因为:① 单条业务路径,递归层数小(典型 < 5);② docs/04 § 3.4 禁循环 N+1 主要针对**列表场景**,单条更新接口的小循环不属于该约束;③ 避免引入 MyBatis-Plus 的 CTE 写法增加复杂度。 + +## 依赖的 schema 表 / 字段 + +写入表:`tModule` + +| 字段 | 用途 | 来源 | +|---|---|---| +| `iIncrement` | path id,定位行 | `@PathVariable` | +| `sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | `UpdateModuleDTO` | +| 其他字段 | 不更新 | — | + +依赖索引:`uk_procedure_name` 不冲突(不动该字段);`fk_module_parent` 在父链校验通过后由 INSERT/UPDATE 默认约束兜底。 + +## 依赖的接口 + +无(仅本 REQ 路径内部使用 MOD-001 已实现的 `ModuleMapper` 工具方法 + 新增父链查询)。 + +## 验收标准 + +### 单元测试(追加到 `ModuleServiceImplTest`) + +- [x] `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` — Mock `selectById` 返回非空、`existsActiveById` 返回 true(若有 parent);断言传入 `updateById` 的 entity:` iIncrement` 是路径 id;`sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 全部为 null(NOT_NULL 策略跳过);可改字段被透传。 +- [x] `updateWithTargetNotFound_throws40400` — Mock `selectById` 返回 null;不调 `updateById`。 +- [x] `updateWithInvalidDisplayType_throws40010` — DTO `sDisplayType="未知"`;不调 `updateById`。 +- [x] `updateWithSelfParentId_throws40021` — DTO `iParentId == path id`;错误信息含"自身"。 +- [x] `updateWithMissingParent_throws40021` — Mock `existsActiveById(parent) → false`;错误信息含"父模块不存在"。 +- [x] `updateWithCyclicParent_throws40021` — 构造 mapper 行为:`existsActiveById(parent)=true`;递归向上 `selectById(parent).getIParentId() == path id`;期望抛 40021,错误信息含"环路"。 +- [x] `updateWithBShowPermissionNull_setsFalseInEntity` — DTO `bShowPermission=null`;entity 字段为 `false`。 + +### 集成测试(追加到 `ModuleControllerIT`) + +- [x] `putValidBody_with_jwt_returns200_andUpdatesEditableFields` — 先 INSERT 一条原始行,再 PUT;查 DB:可改字段为新值,`sProcedureName` / `sCreatedBy` 保持原值。 +- [x] `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999`;`code=40400`。 +- [x] `putInvalidDisplayType_returns40010` — `code=40010`。 +- [x] `putSelfParent_returns40021` — body `iParentId == path id`;`code=40021`。 +- [x] `putCyclicParent_returns40021` — 准备数据:root → child;PUT root 把 `iParentId` 改成 child(构成环);`code=40021`。 +- [x] `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先 INSERT(通过 POST 接口或 JdbcTemplate),再无 token PUT;`sCreatedBy` 仍是原值(不被覆盖为 STUB_ADMIN)。 +- [x] `putTamperedJwt_returns20001` — `code=20001`。 + +### 工程验收 + +- [x] `cd backend && mvn -B test` 全绿(含 MOD-001 已有 26 用例 + 本 REQ 新增至少 7+7=14 用例,总 ≥ 40 用例) +- [x] SecurityConfig 路径规则更新后,MOD-001 已有 IT 仍 PASS(permitAll 范围扩大不收紧) +- [x] DB 中 `sProcedureName` 在更新前后字面相同(验证未被覆盖) -- libgit2 0.22.2