---
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 形如 `<type>(mod): <subject> 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<Map<String,Integer>> 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 |
