Commit c916236225e8d908dc13da4eb05c8aecc15add0e
1 parent
d5b68c9e
docs(mod): spec + plan REQ-MOD-003
Showing
2 changed files
with
237 additions
and
0 deletions
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` 锚点保持不动 |