Commit c916236225e8d908dc13da4eb05c8aecc15add0e

Authored by zichun
1 parent d5b68c9e

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

docs/superpowers/plans/2026-04-29-REQ-MOD-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-MOD-003
  3 +date: 2026-04-29
  4 +spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-003.md
  5 +---
  6 +
  7 +# REQ-MOD-003 模块删除 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 在 MOD-001/002 已建工程基础上增量实现 `DELETE /api/mod/modules/{id}` 软删除接口,含目标存在性、子模块拦截两类校验,软删除后 `bDeleted=1` + 审计字段。
  12 +
  13 +**Architecture:** 复用现有 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper`;新增 `mapper.hasActiveChildren(id)` + `service.delete(id)` + controller `@DeleteMapping`。SecurityConfig 已对 `/api/mod/**` permitAll,无需改。`sDeletedBy` 取 JWT principal 或回退 stub(与 MOD-001 `sCreatedBy` 同策略)。**40902 外部引用拦截不实现**——docs/03 当前 schema 中 tModule 无引用方表。
  14 +
  15 +**Tech Stack:** Spring Boot 3.3.5 / MyBatis-Plus / JUnit 5 + Mockito + TestRestTemplate(沿用)。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(仅 UPDATE 软删除字段)。
  22 +
  23 +## 文件变更清单
  24 +
  25 +### 修改
  26 +
  27 +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `findActiveChildFlag` + default `hasActiveChildren`
  28 +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `void delete(Integer id)` 方法
  29 +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `delete(...)`
  30 +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@DeleteMapping("/modules/{id}")` 端点
  31 +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 5 用例
  32 +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 用例
  33 +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 用例
  34 +
  35 +## 任务步骤
  36 +
  37 +> 全局:每 commit `<type>(mod): <subject> REQ-MOD-003`;测试派发子会话;现有 41 用例全程绿。
  38 +
  39 +### Task 1: Mapper#hasActiveChildren + IT
  40 +
  41 +**Files:**
  42 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java`
  43 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java`
  44 +
  45 +**API shape:**
  46 +- `@Select("SELECT 1 FROM tModule WHERE iParentId = #{parentId} AND bDeleted = 0 LIMIT 1")` `Integer findActiveChildFlag(@Param("parentId") Integer parentId)`
  47 +- `default boolean hasActiveChildren(Integer parentId) { return findActiveChildFlag(parentId) != null; }`
  48 +
  49 +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#hasActiveChildren_trueIfChildAliveExists_falseOtherwise`**
  50 + - 准备:root(无 parent);child1(parent=root, bDeleted=0);child2(parent=root, bDeleted=1)
  51 + - 断言:`hasActiveChildren(root.id) == true`
  52 + - 用 JdbcTemplate `UPDATE tModule SET bDeleted=1 WHERE iIncrement=child1.id` 软删唯一活跃子节点
  53 + - 再次断言:`hasActiveChildren(root.id) == false`
  54 + - `hasActiveChildren(99999997) == false`
  55 +
  56 +- [ ] **Step 2: 实现 mapper 方法**
  57 +
  58 +- [ ] **Step 3: 子会话验证 PASS**
  59 + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT`
  60 +
  61 +- [ ] **Step 4: Commit**
  62 + - `git commit -m "feat(mod): mapper#hasActiveChildren for delete check REQ-MOD-003"`
  63 +
  64 +### Task 2: Service#delete + 单测
  65 +
  66 +**Files:**
  67 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`
  68 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`
  69 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`
  70 +
  71 +**API shape:**
  72 +- `ModuleService#delete(Integer id) : void`
  73 +- `ModuleServiceImpl#delete(Integer id)`:
  74 + 1. `Module original = moduleMapper.selectById(id)` → null 或 `bDeleted=true` → `BizException(40400, "模块不存在或已删除")`
  75 + 2. `moduleMapper.hasActiveChildren(id)` → true → `BizException(40901, "模块仍有未删除子节点")`
  76 + 3. 构造 `Module entity`:`setIIncrement(id)` / `setBDeleted(true)` / `setTDeletedDate(LocalDateTime.now())` / `setSDeletedBy(SecurityContextHelper.currentUserNo() ?: stub.getStubUserNo())`;其他字段 null
  77 + 4. `moduleMapper.updateById(entity)`
  78 +
  79 +- [ ] **Step 1: 写失败测试(5 用例)**
  80 + - `deleteWithValidId_softDeletes_andSetsAuditFields`:mock `selectById(10)=alive`、`hasActiveChildren(10)=false`、`updateById(any)=1`;ArgumentCaptor 抓 entity;断言 `iIncrement=10` / `bDeleted=true` / `tDeletedDate != null` / `sDeletedBy="STUB_ADMIN"` / 其他字段 null
  81 + - `deleteWithTargetNotFound_throws40400`:`selectById(99)=null`;`updateById` 永不调用
  82 + - `deleteWithTargetAlreadyDeleted_throws40400`:`selectById(10)` 返回 `bDeleted=true` 的 Module
  83 + - `deleteWithActiveChildren_throws40901`:`hasActiveChildren(10)=true`
  84 + - `deleteSetsDeletedByFromAuthenticatedUser`:SecurityContextHolder 注入 principal "BOB";ArgumentCaptor `sDeletedBy="BOB"`
  85 + - 子会话先跑 → 5 用例 FAIL
  86 +
  87 +- [ ] **Step 2: 实现 service**
  88 + - 严格按 API shape 顺序
  89 +
  90 +- [ ] **Step 3: 子会话验证 PASS**
  91 + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest`
  92 + - 期望:13 (前) + 5 = 18 用例全绿
  93 +
  94 +- [ ] **Step 4: Commit**
  95 + - `git commit -m "feat(mod): module delete service + soft delete REQ-MOD-003"`
  96 +
  97 +### Task 3: Controller DELETE + 6 IT 用例
  98 +
  99 +**Files:**
  100 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java`
  101 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`
  102 +
  103 +**API shape:**
  104 +- `@DeleteMapping("/modules/{id}") public Result<Void> delete(@PathVariable Integer id)`
  105 +- 调 `moduleService.delete(id)`;返回 `Result.ok()`
  106 +
  107 +- [ ] **Step 1: 写失败测试(6 用例)**
  108 + - `deleteValidId_with_jwt_returns200_andSoftDeletes`:JdbcTemplate 直插 alive 行;DELETE 带 JWT="ADMIN001";期望 `code=0` / `data=null`;JdbcTemplate 查 `bDeleted=1` / `sDeletedBy="ADMIN001"` / `tDeletedDate IS NOT NULL` / `sProcedureName` 不变 / `sCreatedBy` 不变
  109 + - `deleteNonExistentId_returns40400`:DELETE `/api/mod/modules/99999996` → `code=40400`
  110 + - `deleteAlreadyDeletedId_returns40400`:JdbcTemplate 直插 `bDeleted=1` 行;DELETE → `code=40400`
  111 + - `deleteWithActiveChildren_returns40901`:JdbcTemplate 直插 root + child(bDeleted=0, parent=root);DELETE root → `code=40901`;JdbcTemplate 查 root 仍 `bDeleted=0`
  112 + - `deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB`:JdbcTemplate 直插 alive;无 token DELETE;DB 查 `sDeletedBy="STUB_ADMIN"` / `bDeleted=1`
  113 + - `deleteTamperedJwt_returns20001`:JdbcTemplate 直插 alive;Authorization "Bearer not.a.real.jwt" DELETE;`code=20001`;DB 查行 `bDeleted=0`(filter 短路,service 未触发)
  114 + - 6 用例先跑 → FAIL(controller 不存在 → 405/404)
  115 +
  116 +- [ ] **Step 2: 实现 controller DELETE**
  117 +
  118 +- [ ] **Step 3: 子会话跑全量回归**
  119 + - 命令:`cd backend && mvn -B test`
  120 + - 期望:MOD-001 26 + MOD-002 15 + MOD-003 新增 1(mapperIT) + 5(svc) + 6(it) = 53 用例全绿
  121 +
  122 +- [ ] **Step 4: Commit**
  123 + - `git commit -m "test(mod): module delete integration coverage REQ-MOD-003"`
  124 +
  125 +## 提交计划
  126 +
  127 +| commit | 覆盖 |
  128 +|---|---|
  129 +| `feat(mod): mapper#hasActiveChildren for delete check REQ-MOD-003` | Task 1 |
  130 +| `feat(mod): module delete service + soft delete REQ-MOD-003` | Task 2 |
  131 +| `test(mod): module delete integration coverage REQ-MOD-003` | Task 3 |
... ...
docs/superpowers/specs/2026-04-29-REQ-MOD-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-MOD-003
  3 +date: 2026-04-29
  4 +module: module_mod
  5 +---
  6 +
  7 +# Spec: REQ-MOD-003 — 模块删除
  8 +
  9 +## 目标
  10 +
  11 +软删除一个已有模块(`bDeleted=0 → 1` + 审计字段填充),并阻止破坏树形数据完整性的删除(已存在未删子模块时拒绝)。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +### HTTP 接口(docs/05 § REQ-MOD-003)
  16 +
  17 +- Method / Path: `DELETE /api/mod/modules/{id}`(path 参数 `{id}` = `tModule.iIncrement`)
  18 +- 无请求 body
  19 +- Auth: 必需(沿用 MOD-001 stub:路径已在 SecurityConfig `/api/mod/**` permitAll,USR-004 完成后改 `hasAuthority('SUPER_ADMIN')`)
  20 +- Permission: 仅超级管理员(stub 期不强制)
  21 +
  22 +### 鉴权与上下文
  23 +
  24 +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 token → `code=20001`;缺失 token → permitAll 透传。`sDeletedBy` 取 `SecurityContextHelper.currentUserNo()`,匿名状态回退 `stubProps.stubUserNo`(与 MOD-001 `sCreatedBy` 同策略)。
  25 +
  26 +## 输出 / 结果
  27 +
  28 +### 成功响应
  29 +
  30 +```json
  31 +{ "code": 0, "msg": "ok", "data": null }
  32 +```
  33 +
  34 +### 持久化效果
  35 +
  36 +`UPDATE tModule SET bDeleted=1, tDeletedDate=NOW(), sDeletedBy='<userNo>' WHERE iIncrement={id}`。其他字段保持原值(依赖 MyBatis-Plus FieldStrategy.NOT_NULL 仅写非 null 字段)。
  37 +
  38 +## 业务规则
  39 +
  40 +1. **目标存在性**:`SELECT * FROM tModule WHERE iIncrement={id}`;行不存在 **或** `bDeleted=1` → `BizException(40400, "模块不存在或已删除")`。已删模块再次 DELETE 也返回 40400(删除接口非幂等设计——业务上想表达"目标已不存在")。
  41 +2. **子模块拦截**:检查 `tModule WHERE iParentId={id} AND bDeleted=0`;存在 → `BizException(40901, "模块仍有未删除子节点")`。新增 mapper 方法 `boolean hasActiveChildren(Integer parentId)`,实现用 `@Select("SELECT 1 FROM tModule WHERE iParentId = #{parentId} AND bDeleted = 0 LIMIT 1")` + Java default 包装。
  42 +3. **外部引用拦截(40902)**:docs/05 列了该错误码,但 docs/03 § tModule 业务注记明确"与本期其他表无外键关系"——本期 schema 中**不存在**菜单/权限/角色表引用 tModule 的字段,**本 REQ 不实现 40902 校验**。spec 显式记录该决策:当 USR 或后续模块引入 `tMenu` / `tRole` 等表并通过 `iModuleId` 等字段引用 tModule 时,再回头扩展 ModuleService#delete 加引用查询。当前实现保持纯 MOD 模块自包含。
  43 +4. **软删除字段填充**:构造 `Module entity` 仅 set `iIncrement` / `bDeleted=true` / `tDeletedDate=LocalDateTime.now()` / `sDeletedBy=<userNo or stub>`;其他字段保持 null(NOT_NULL 跳过,避免覆盖原值)。
  44 +5. **事务边界**:复用类级 `@Transactional(rollbackFor = Exception.class)`,包裹"目标查询 + 子模块查询 + UPDATE"。
  45 +
  46 +## 边界与约束
  47 +
  48 +- **id 不存在 / 已软删** → `40400`
  49 +- **存在未删子模块** → `40901`
  50 +- **JWT 伪造** → `20001`(filter 短路)
  51 +- **JWT 缺失** → permitAll stub,正常 200 + `sDeletedBy=STUB_ADMIN`(USR-004 闭环后改 401)
  52 +- **40902 外部引用拦截** → 本 REQ 不实现(docs/03 当前 schema 无引用方),spec 记录后续补点
  53 +
  54 +## 实现范围与边界抉择
  55 +
  56 +1. **复用 MOD-001/002 工程**:无新增依赖;仅在 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` 上做增量。
  57 +2. **删除接口非幂等**:连续 DELETE 同一 id,第二次返回 40400,不允许覆盖已删除记录的 `tDeletedDate` / `sDeletedBy`。这与"删除"语义对齐——目标不存在就是错。
  58 +3. **mapper 仅查 1 行不查全表**:`hasActiveChildren` 用 `SELECT 1 ... LIMIT 1` 避免全表扫描;与 MOD-001 `findActiveFlagById` 风格一致。
  59 +4. **不做 40902**:待引用方落地。
  60 +
  61 +## 依赖的 schema 表 / 字段
  62 +
  63 +写入表:`tModule`
  64 +
  65 +| 字段 | 用途 | 来源 |
  66 +|---|---|---|
  67 +| `iIncrement` | path id,定位行 | `@PathVariable` |
  68 +| `bDeleted` | 软删除标记 | service 设 `true` |
  69 +| `tDeletedDate` | 软删除时间 | `LocalDateTime.now()` |
  70 +| `sDeletedBy` | 软删除操作人 | JWT principal 或 `stubProps.stubUserNo` |
  71 +| 其他字段 | 不动 | — |
  72 +
  73 +读取表:`tModule`(含子模块查询)。
  74 +
  75 +## 依赖的接口
  76 +
  77 +无(本 REQ 内部使用 MOD-001/002 已有 mapper 工具方法 + 新增 `hasActiveChildren`)。
  78 +
  79 +## 验收标准
  80 +
  81 +### 单元测试(追加到 `ModuleServiceImplTest`)
  82 +
  83 +- [x] `deleteWithValidId_softDeletes_andSetsAuditFields` — Mock `selectById(10)` 返回 alive Module,`hasActiveChildren(10)=false`;ArgumentCaptor 抓 `updateById` 入参,断言 `iIncrement=10` / `bDeleted=true` / `tDeletedDate != null` / `sDeletedBy="STUB_ADMIN"`(无认证上下文);其他字段 null。
  84 +- [x] `deleteWithTargetNotFound_throws40400` — `selectById(99)=null`;不调 `updateById`。
  85 +- [x] `deleteWithTargetAlreadyDeleted_throws40400` — `selectById(10)` 返回 `bDeleted=true` 的 Module;不调 `updateById`。
  86 +- [x] `deleteWithActiveChildren_throws40901` — `selectById(10)` alive;`hasActiveChildren(10)=true`;不调 `updateById`。
  87 +- [x] `deleteSetsDeletedByFromAuthenticatedUser` — SecurityContextHolder 注入 principal `"BOB"`;ArgumentCaptor `sDeletedBy="BOB"`。
  88 +
  89 +### Mapper IT(追加到 `ModuleMapperIT`)
  90 +
  91 +- [x] `hasActiveChildren_trueIfChildAliveExists_falseOtherwise` — 准备 root + alive child + deleted child;断言 `hasActiveChildren(root)=true`;删除 alive child 后再断言 `hasActiveChildren(root)=false`(用 JdbcTemplate UPDATE bDeleted=1)。
  92 +
  93 +### 集成测试(追加到 `ModuleControllerIT`)
  94 +
  95 +- [x] `deleteValidId_with_jwt_returns200_andSoftDeletes` — 直插一行 alive;DELETE 带 JWT="ADMIN001";期望 `code=0` / `data=null`;DB 查 `bDeleted=1` / `sDeletedBy="ADMIN001"` / `tDeletedDate IS NOT NULL`;其他列保持原值(断 `sProcedureName` / `sCreatedBy` / `sBrandsId`)。
  96 +- [x] `deleteNonExistentId_returns40400` — DELETE `/api/mod/modules/99999997`;`code=40400`。
  97 +- [x] `deleteAlreadyDeletedId_returns40400` — 直插 `bDeleted=1` 行;DELETE → `code=40400`。
  98 +- [x] `deleteWithActiveChildren_returns40901` — 直插 root + alive child;DELETE root → `code=40901`;DB 查 root 仍 `bDeleted=0`。
  99 +- [x] `deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB` — 直插 alive;无 token DELETE;`code=0`;DB 查 `sDeletedBy="STUB_ADMIN"`。
  100 +- [x] `deleteTamperedJwt_returns20001` — Authorization 伪造 → `code=20001`,DB 行未被改动。
  101 +
  102 +### 工程验收
  103 +
  104 +- [x] `cd backend && mvn -B test` 全绿(含 MOD-001 26 + MOD-002 15 + MOD-003 新增 5 service + 1 mapperIT + 6 controllerIT = 53 用例)
  105 +- [x] DELETE 接口经路径白名单 `/api/mod/**` permitAll 通过
  106 +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持不动
... ...