From c916236225e8d908dc13da4eb05c8aecc15add0e Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 29 Apr 2026 17:58:26 +0800 Subject: [PATCH] docs(mod): spec + plan REQ-MOD-003 --- docs/superpowers/plans/2026-04-29-REQ-MOD-003.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-04-29-REQ-MOD-003.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 0 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-29-REQ-MOD-003.md create mode 100644 docs/superpowers/specs/2026-04-29-REQ-MOD-003.md diff --git a/docs/superpowers/plans/2026-04-29-REQ-MOD-003.md b/docs/superpowers/plans/2026-04-29-REQ-MOD-003.md new file mode 100644 index 0000000..8aefd16 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-REQ-MOD-003.md @@ -0,0 +1,131 @@ +--- +req_id: REQ-MOD-003 +date: 2026-04-29 +spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-003.md +--- + +# REQ-MOD-003 模块删除 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** 在 MOD-001/002 已建工程基础上增量实现 `DELETE /api/mod/modules/{id}` 软删除接口,含目标存在性、子模块拦截两类校验,软删除后 `bDeleted=1` + 审计字段。 + +**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 无引用方表。 + +**Tech Stack:** Spring Boot 3.3.5 / MyBatis-Plus / JUnit 5 + Mockito + TestRestTemplate(沿用)。 + +--- + +## Schema 改动 + +无(仅 UPDATE 软删除字段)。 + +## 文件变更清单 + +### 修改 + +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `findActiveChildFlag` + default `hasActiveChildren` +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `void delete(Integer id)` 方法 +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `delete(...)` +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@DeleteMapping("/modules/{id}")` 端点 +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 5 用例 +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 用例 +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 用例 + +## 任务步骤 + +> 全局:每 commit `(mod): REQ-MOD-003`;测试派发子会话;现有 41 用例全程绿。 + +### Task 1: Mapper#hasActiveChildren + IT + +**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 1 FROM tModule WHERE iParentId = #{parentId} AND bDeleted = 0 LIMIT 1")` `Integer findActiveChildFlag(@Param("parentId") Integer parentId)` +- `default boolean hasActiveChildren(Integer parentId) { return findActiveChildFlag(parentId) != null; }` + +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#hasActiveChildren_trueIfChildAliveExists_falseOtherwise`** + - 准备:root(无 parent);child1(parent=root, bDeleted=0);child2(parent=root, bDeleted=1) + - 断言:`hasActiveChildren(root.id) == true` + - 用 JdbcTemplate `UPDATE tModule SET bDeleted=1 WHERE iIncrement=child1.id` 软删唯一活跃子节点 + - 再次断言:`hasActiveChildren(root.id) == false` + - `hasActiveChildren(99999997) == false` + +- [ ] **Step 2: 实现 mapper 方法** + +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` + +- [ ] **Step 4: Commit** + - `git commit -m "feat(mod): mapper#hasActiveChildren for delete check REQ-MOD-003"` + +### Task 2: Service#delete + 单测 + +**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` +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` + +**API shape:** +- `ModuleService#delete(Integer id) : void` +- `ModuleServiceImpl#delete(Integer id)`: + 1. `Module original = moduleMapper.selectById(id)` → null 或 `bDeleted=true` → `BizException(40400, "模块不存在或已删除")` + 2. `moduleMapper.hasActiveChildren(id)` → true → `BizException(40901, "模块仍有未删除子节点")` + 3. 构造 `Module entity`:`setIIncrement(id)` / `setBDeleted(true)` / `setTDeletedDate(LocalDateTime.now())` / `setSDeletedBy(SecurityContextHelper.currentUserNo() ?: stub.getStubUserNo())`;其他字段 null + 4. `moduleMapper.updateById(entity)` + +- [ ] **Step 1: 写失败测试(5 用例)** + - `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 + - `deleteWithTargetNotFound_throws40400`:`selectById(99)=null`;`updateById` 永不调用 + - `deleteWithTargetAlreadyDeleted_throws40400`:`selectById(10)` 返回 `bDeleted=true` 的 Module + - `deleteWithActiveChildren_throws40901`:`hasActiveChildren(10)=true` + - `deleteSetsDeletedByFromAuthenticatedUser`:SecurityContextHolder 注入 principal "BOB";ArgumentCaptor `sDeletedBy="BOB"` + - 子会话先跑 → 5 用例 FAIL + +- [ ] **Step 2: 实现 service** + - 严格按 API shape 顺序 + +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` + - 期望:13 (前) + 5 = 18 用例全绿 + +- [ ] **Step 4: Commit** + - `git commit -m "feat(mod): module delete service + soft delete REQ-MOD-003"` + +### Task 3: Controller DELETE + 6 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:** +- `@DeleteMapping("/modules/{id}") public Result delete(@PathVariable Integer id)` +- 调 `moduleService.delete(id)`;返回 `Result.ok()` + +- [ ] **Step 1: 写失败测试(6 用例)** + - `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` 不变 + - `deleteNonExistentId_returns40400`:DELETE `/api/mod/modules/99999996` → `code=40400` + - `deleteAlreadyDeletedId_returns40400`:JdbcTemplate 直插 `bDeleted=1` 行;DELETE → `code=40400` + - `deleteWithActiveChildren_returns40901`:JdbcTemplate 直插 root + child(bDeleted=0, parent=root);DELETE root → `code=40901`;JdbcTemplate 查 root 仍 `bDeleted=0` + - `deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB`:JdbcTemplate 直插 alive;无 token DELETE;DB 查 `sDeletedBy="STUB_ADMIN"` / `bDeleted=1` + - `deleteTamperedJwt_returns20001`:JdbcTemplate 直插 alive;Authorization "Bearer not.a.real.jwt" DELETE;`code=20001`;DB 查行 `bDeleted=0`(filter 短路,service 未触发) + - 6 用例先跑 → FAIL(controller 不存在 → 405/404) + +- [ ] **Step 2: 实现 controller DELETE** + +- [ ] **Step 3: 子会话跑全量回归** + - 命令:`cd backend && mvn -B test` + - 期望:MOD-001 26 + MOD-002 15 + MOD-003 新增 1(mapperIT) + 5(svc) + 6(it) = 53 用例全绿 + +- [ ] **Step 4: Commit** + - `git commit -m "test(mod): module delete integration coverage REQ-MOD-003"` + +## 提交计划 + +| commit | 覆盖 | +|---|---| +| `feat(mod): mapper#hasActiveChildren for delete check REQ-MOD-003` | Task 1 | +| `feat(mod): module delete service + soft delete REQ-MOD-003` | Task 2 | +| `test(mod): module delete integration coverage REQ-MOD-003` | Task 3 | diff --git a/docs/superpowers/specs/2026-04-29-REQ-MOD-003.md b/docs/superpowers/specs/2026-04-29-REQ-MOD-003.md new file mode 100644 index 0000000..5318d53 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-REQ-MOD-003.md @@ -0,0 +1,106 @@ +--- +req_id: REQ-MOD-003 +date: 2026-04-29 +module: module_mod +--- + +# Spec: REQ-MOD-003 — 模块删除 + +## 目标 + +软删除一个已有模块(`bDeleted=0 → 1` + 审计字段填充),并阻止破坏树形数据完整性的删除(已存在未删子模块时拒绝)。 + +## 输入 / 触发 + +### HTTP 接口(docs/05 § REQ-MOD-003) + +- Method / Path: `DELETE /api/mod/modules/{id}`(path 参数 `{id}` = `tModule.iIncrement`) +- 无请求 body +- Auth: 必需(沿用 MOD-001 stub:路径已在 SecurityConfig `/api/mod/**` permitAll,USR-004 完成后改 `hasAuthority('SUPER_ADMIN')`) +- Permission: 仅超级管理员(stub 期不强制) + +### 鉴权与上下文 + +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 token → `code=20001`;缺失 token → permitAll 透传。`sDeletedBy` 取 `SecurityContextHelper.currentUserNo()`,匿名状态回退 `stubProps.stubUserNo`(与 MOD-001 `sCreatedBy` 同策略)。 + +## 输出 / 结果 + +### 成功响应 + +```json +{ "code": 0, "msg": "ok", "data": null } +``` + +### 持久化效果 + +`UPDATE tModule SET bDeleted=1, tDeletedDate=NOW(), sDeletedBy='' WHERE iIncrement={id}`。其他字段保持原值(依赖 MyBatis-Plus FieldStrategy.NOT_NULL 仅写非 null 字段)。 + +## 业务规则 + +1. **目标存在性**:`SELECT * FROM tModule WHERE iIncrement={id}`;行不存在 **或** `bDeleted=1` → `BizException(40400, "模块不存在或已删除")`。已删模块再次 DELETE 也返回 40400(删除接口非幂等设计——业务上想表达"目标已不存在")。 +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 包装。 +3. **外部引用拦截(40902)**:docs/05 列了该错误码,但 docs/03 § tModule 业务注记明确"与本期其他表无外键关系"——本期 schema 中**不存在**菜单/权限/角色表引用 tModule 的字段,**本 REQ 不实现 40902 校验**。spec 显式记录该决策:当 USR 或后续模块引入 `tMenu` / `tRole` 等表并通过 `iModuleId` 等字段引用 tModule 时,再回头扩展 ModuleService#delete 加引用查询。当前实现保持纯 MOD 模块自包含。 +4. **软删除字段填充**:构造 `Module entity` 仅 set `iIncrement` / `bDeleted=true` / `tDeletedDate=LocalDateTime.now()` / `sDeletedBy=`;其他字段保持 null(NOT_NULL 跳过,避免覆盖原值)。 +5. **事务边界**:复用类级 `@Transactional(rollbackFor = Exception.class)`,包裹"目标查询 + 子模块查询 + UPDATE"。 + +## 边界与约束 + +- **id 不存在 / 已软删** → `40400` +- **存在未删子模块** → `40901` +- **JWT 伪造** → `20001`(filter 短路) +- **JWT 缺失** → permitAll stub,正常 200 + `sDeletedBy=STUB_ADMIN`(USR-004 闭环后改 401) +- **40902 外部引用拦截** → 本 REQ 不实现(docs/03 当前 schema 无引用方),spec 记录后续补点 + +## 实现范围与边界抉择 + +1. **复用 MOD-001/002 工程**:无新增依赖;仅在 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` 上做增量。 +2. **删除接口非幂等**:连续 DELETE 同一 id,第二次返回 40400,不允许覆盖已删除记录的 `tDeletedDate` / `sDeletedBy`。这与"删除"语义对齐——目标不存在就是错。 +3. **mapper 仅查 1 行不查全表**:`hasActiveChildren` 用 `SELECT 1 ... LIMIT 1` 避免全表扫描;与 MOD-001 `findActiveFlagById` 风格一致。 +4. **不做 40902**:待引用方落地。 + +## 依赖的 schema 表 / 字段 + +写入表:`tModule` + +| 字段 | 用途 | 来源 | +|---|---|---| +| `iIncrement` | path id,定位行 | `@PathVariable` | +| `bDeleted` | 软删除标记 | service 设 `true` | +| `tDeletedDate` | 软删除时间 | `LocalDateTime.now()` | +| `sDeletedBy` | 软删除操作人 | JWT principal 或 `stubProps.stubUserNo` | +| 其他字段 | 不动 | — | + +读取表:`tModule`(含子模块查询)。 + +## 依赖的接口 + +无(本 REQ 内部使用 MOD-001/002 已有 mapper 工具方法 + 新增 `hasActiveChildren`)。 + +## 验收标准 + +### 单元测试(追加到 `ModuleServiceImplTest`) + +- [x] `deleteWithValidId_softDeletes_andSetsAuditFields` — Mock `selectById(10)` 返回 alive Module,`hasActiveChildren(10)=false`;ArgumentCaptor 抓 `updateById` 入参,断言 `iIncrement=10` / `bDeleted=true` / `tDeletedDate != null` / `sDeletedBy="STUB_ADMIN"`(无认证上下文);其他字段 null。 +- [x] `deleteWithTargetNotFound_throws40400` — `selectById(99)=null`;不调 `updateById`。 +- [x] `deleteWithTargetAlreadyDeleted_throws40400` — `selectById(10)` 返回 `bDeleted=true` 的 Module;不调 `updateById`。 +- [x] `deleteWithActiveChildren_throws40901` — `selectById(10)` alive;`hasActiveChildren(10)=true`;不调 `updateById`。 +- [x] `deleteSetsDeletedByFromAuthenticatedUser` — SecurityContextHolder 注入 principal `"BOB"`;ArgumentCaptor `sDeletedBy="BOB"`。 + +### Mapper IT(追加到 `ModuleMapperIT`) + +- [x] `hasActiveChildren_trueIfChildAliveExists_falseOtherwise` — 准备 root + alive child + deleted child;断言 `hasActiveChildren(root)=true`;删除 alive child 后再断言 `hasActiveChildren(root)=false`(用 JdbcTemplate UPDATE bDeleted=1)。 + +### 集成测试(追加到 `ModuleControllerIT`) + +- [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`)。 +- [x] `deleteNonExistentId_returns40400` — DELETE `/api/mod/modules/99999997`;`code=40400`。 +- [x] `deleteAlreadyDeletedId_returns40400` — 直插 `bDeleted=1` 行;DELETE → `code=40400`。 +- [x] `deleteWithActiveChildren_returns40901` — 直插 root + alive child;DELETE root → `code=40901`;DB 查 root 仍 `bDeleted=0`。 +- [x] `deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB` — 直插 alive;无 token DELETE;`code=0`;DB 查 `sDeletedBy="STUB_ADMIN"`。 +- [x] `deleteTamperedJwt_returns20001` — Authorization 伪造 → `code=20001`,DB 行未被改动。 + +### 工程验收 + +- [x] `cd backend && mvn -B test` 全绿(含 MOD-001 26 + MOD-002 15 + MOD-003 新增 5 service + 1 mapperIT + 6 controllerIT = 53 用例) +- [x] DELETE 接口经路径白名单 `/api/mod/**` permitAll 通过 +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持不动 -- libgit2 0.22.2