--- 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` 锚点保持不动