--- req_id: REQ-MOD-002 date: 2026-04-29 module: module_mod --- # Spec: REQ-MOD-002 — 模块修改 ## 目标 在不破坏唯一性的前提下,更新已有模块的可编辑字段:`sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder`。`sProcedureName`、`sCreatedBy`、`tCreateDate`、`sBrandsId` / `sSubsidiaryId`、软删除字段一律保留原值。 ## 输入 / 触发 ### HTTP 接口(来自 docs/05 § REQ-MOD-002) - Method / Path: `PUT /api/mod/modules/{id}`(`{id}` = `tModule.iIncrement`) - Auth: 必需(JWT Bearer) - Permission: 仅超级管理员(**沿用 MOD-001 的 stub:SecurityConfig 路径范围扩展为 `/api/mod/**` permitAll,USR-004 完成后统一改 `hasAuthority('SUPER_ADMIN')`**) ### 请求 DTO `UpdateModuleDTO` | JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | |---|---|---|---|---| | `sDisplayType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[手机端, 前端业务, 系统配置, 接口]` 内;非法 → `40010` | | `sModuleType` | `String` | 是 | `@NotBlank @Size(max=50)` | 自由文本 | | `sManageDeptEn` | `String` | 是 | `@NotBlank @Size(max=50)` | — | | `bShowPermission` | `Boolean` | 否 | — | 缺省视为 `false`(写 0) | | `sModuleNameZh` | `String` | 是 | `@NotBlank @Size(max=100)` | — | | `iParentId` | `Integer` | 否 | — | 不为 null 时:① 不能等于路径 `{id}`(自指);② 必须命中存在且 `bDeleted=0` 的记录;③ 沿父链遍历不能在路径中出现 `{id}`(环检测)。三种违反统一 → `40021` | | `iSortOrder` | `Integer` | 否 | — | 缺省 `0` | > **`sProcedureName` 显式从 DTO 中剔除**——API 契约声明该字段不可改;前端表单仍可显示原值(REQ 卡列为必填仅是前端 UX 约束),但 PUT body 中即便传了也会被 Jackson 丢弃,不进入 service。这一处与 REQ 卡输入表的差异是**有意的**:以 docs/05 API 契约为准。 ### 鉴权与上下文 同 MOD-001:JWT Filter 解析 token 写 `principal=sUserNo`;本 REQ 走 `permitAll` stub,不强制要求 token;伪造 token 仍被 filter 短路返回 `code=20001`。`sCreatedBy` 在更新时**不修改**,无论是否携带 token。 ## 输出 / 结果 ### 成功响应 ```json { "code": 0, "msg": "ok", "data": { "iIncrement": 123 } } ``` ### 持久化效果 `UPDATE tModule SET <可编辑列> WHERE iIncrement = {id}`。 | 字段 | 更新策略 | |---|---| | `sDisplayType` / `sModuleType` / `sManageDeptEn` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | | `bShowPermission` | DTO null → `false`,否则 DTO 值 | | `sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `bDeleted` / `tDeletedDate` / `sDeletedBy` / `sId` | **不更新**(entity 上对应字段保持 null,依赖 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL` 跳过 null 字段) | > 实施细节:MyBatis-Plus 默认全局 `update-strategy: NOT_NULL`,但需在 entity 字段上不显式标注其他 strategy。`bShowPermission` 因 DTO null 时要写 false,故 service 层先把 DTO null 展开为 false 再赋值给 entity;其余 null 字段保持 null。 ## 业务规则 1. **目标存在性**:先 `SELECT * FROM tModule WHERE iIncrement = {id} AND bDeleted = 0`;找不到 → `BizException(40400, "模块不存在或已删除")`。 2. **枚举校验**:`sDisplayType` 在 `DISPLAY_TYPES` 内(复用 `ModuleServiceImpl.DISPLAY_TYPES`);非法 → `BizException(40010, "显示类型枚举不合法")`。 > docs/05 § MOD-002 错误码表只列了 40001/40021/40400,未单列 40010。本实现选择复用 MOD-001 已建立的 40010 语义(保持同一字段两个接口的错误码一致)。这条偏离已记入 spec,后续若需统一可在 docs/05 补一行。 3. **iParentId 校验**(`iParentId != null` 时): - 自指 (`iParentId == id`) → `BizException(40021, "父模块不能指向自身")` - 不存在 / 已软删 (`!moduleMapper.existsActiveById(iParentId)`) → `BizException(40021, "父模块不存在或已删除")` - 形成环:从 `iParentId` 沿 `tModule.iParentId` 链向上回溯,每跳一次查一次 mapper;若某层 ID 等于路径 `{id}` → `BizException(40021, "父模块链构成环路")`;遍历深度上限 `50`,超限抛 `BizException(40021, "父模块链超过最大层级")` 防止脏数据死循环。 4. **事务边界**:`update(...)` 上 `@Transactional(rollbackFor = Exception.class)`,包裹"目标查询 + 父链校验 + UPDATE"全部步骤。 5. **空 body / 非 JSON**:交给 Spring + GlobalExceptionHandler,目前会落 `handleAny` 转 `code=50000`(同 MOD-001 已知行为,spec 未要求 fix)。 ## 边界与约束 - **必填项缺失** → `40001` - **`sDisplayType` 非枚举** → `40010` - **iParentId 不合法(自指 / 不存在 / 环 / 超深)** → `40021` - **目标 id 不存在或已软删** → `40400` - **JWT 伪造** → `20001`(filter 短路) - **JWT 缺失** → permitAll stub,不阻断(USR-004 后改 401) - **不允许修改 `sProcedureName`**:DTO 直接不暴露该字段;即便前端误传,service 也不读;不需要单独错误码。 ## 实现范围与边界抉择 1. **复用 MOD-001 工程脚手架**:无需新增 pom 依赖、Application、SecurityConfig 等;仅在 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` 上做增量。 2. **SecurityConfig 路径调整**:把 MOD-001 的 `POST /api/mod/modules permitAll` 改为 `requestMatchers("/api/mod/**").permitAll()`,stub 范围一次性覆盖整个 MOD 模块的 4 个 REQ;注释保留 `// REQ-MOD-001 stub: see USR-004 follow-up`(路径更动,原 stub 锚点继续生效,无需新增锚点关键字)。 3. **环检测策略**:选择"递归向上查 mapper"而非"DB 层 CTE",因为:① 单条业务路径,递归层数小(典型 < 5);② docs/04 § 3.4 禁循环 N+1 主要针对**列表场景**,单条更新接口的小循环不属于该约束;③ 避免引入 MyBatis-Plus 的 CTE 写法增加复杂度。 ## 依赖的 schema 表 / 字段 写入表:`tModule` | 字段 | 用途 | 来源 | |---|---|---| | `iIncrement` | path id,定位行 | `@PathVariable` | | `sDisplayType` / `sModuleType` / `sManageDeptEn` / `bShowPermission` / `sModuleNameZh` / `iParentId` / `iSortOrder` | DTO 透传 | `UpdateModuleDTO` | | 其他字段 | 不更新 | — | 依赖索引:`uk_procedure_name` 不冲突(不动该字段);`fk_module_parent` 在父链校验通过后由 INSERT/UPDATE 默认约束兜底。 ## 依赖的接口 无(仅本 REQ 路径内部使用 MOD-001 已实现的 `ModuleMapper` 工具方法 + 新增父链查询)。 ## 验收标准 ### 单元测试(追加到 `ModuleServiceImplTest`) - [x] `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` — Mock `selectById` 返回非空、`existsActiveById` 返回 true(若有 parent);断言传入 `updateById` 的 entity:` iIncrement` 是路径 id;`sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 全部为 null(NOT_NULL 策略跳过);可改字段被透传。 - [x] `updateWithTargetNotFound_throws40400` — Mock `selectById` 返回 null;不调 `updateById`。 - [x] `updateWithInvalidDisplayType_throws40010` — DTO `sDisplayType="未知"`;不调 `updateById`。 - [x] `updateWithSelfParentId_throws40021` — DTO `iParentId == path id`;错误信息含"自身"。 - [x] `updateWithMissingParent_throws40021` — Mock `existsActiveById(parent) → false`;错误信息含"父模块不存在"。 - [x] `updateWithCyclicParent_throws40021` — 构造 mapper 行为:`existsActiveById(parent)=true`;递归向上 `selectById(parent).getIParentId() == path id`;期望抛 40021,错误信息含"环路"。 - [x] `updateWithBShowPermissionNull_setsFalseInEntity` — DTO `bShowPermission=null`;entity 字段为 `false`。 ### 集成测试(追加到 `ModuleControllerIT`) - [x] `putValidBody_with_jwt_returns200_andUpdatesEditableFields` — 先 INSERT 一条原始行,再 PUT;查 DB:可改字段为新值,`sProcedureName` / `sCreatedBy` 保持原值。 - [x] `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999`;`code=40400`。 - [x] `putInvalidDisplayType_returns40010` — `code=40010`。 - [x] `putSelfParent_returns40021` — body `iParentId == path id`;`code=40021`。 - [x] `putCyclicParent_returns40021` — 准备数据:root → child;PUT root 把 `iParentId` 改成 child(构成环);`code=40021`。 - [x] `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先 INSERT(通过 POST 接口或 JdbcTemplate),再无 token PUT;`sCreatedBy` 仍是原值(不被覆盖为 STUB_ADMIN)。 - [x] `putTamperedJwt_returns20001` — `code=20001`。 ### 工程验收 - [x] `cd backend && mvn -B test` 全绿(含 MOD-001 已有 26 用例 + 本 REQ 新增至少 7+7=14 用例,总 ≥ 40 用例) - [x] SecurityConfig 路径规则更新后,MOD-001 已有 IT 仍 PASS(permitAll 范围扩大不收紧) - [x] DB 中 `sProcedureName` 在更新前后字面相同(验证未被覆盖)