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。
输出 / 结果
成功响应
{
"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。
业务规则
-
目标存在性:先
SELECT * FROM tModule WHERE iIncrement = {id} AND bDeleted = 0;找不到 →BizException(40400, "模块不存在或已删除")。 -
枚举校验:
sDisplayType在DISPLAY_TYPES内(复用ModuleServiceImpl.DISPLAY_TYPES);非法 →BizException(40010, "显示类型枚举不合法")。 > docs/05 § MOD-002 错误码表只列了 40001/40021/40400,未单列 40010。本实现选择复用 MOD-001 已建立的 40010 语义(保持同一字段两个接口的错误码一致)。这条偏离已记入 spec,后续若需统一可在 docs/05 补一行。 -
iParentId 校验(
iParentId != null时):- 自指 (
iParentId == id) →BizException(40021, "父模块不能指向自身") - 不存在 / 已软删 (
!moduleMapper.existsActiveById(iParentId)) →BizException(40021, "父模块不存在或已删除") - 形成环:从
iParentId沿tModule.iParentId链向上回溯,每跳一次查一次 mapper;若某层 ID 等于路径{id}→BizException(40021, "父模块链构成环路");遍历深度上限50,超限抛BizException(40021, "父模块链超过最大层级")防止脏数据死循环。
- 自指 (
-
事务边界:
update(...)上@Transactional(rollbackFor = Exception.class),包裹"目标查询 + 父链校验 + UPDATE"全部步骤。 -
空 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 也不读;不需要单独错误码。
实现范围与边界抉择
-
复用 MOD-001 工程脚手架:无需新增 pom 依赖、Application、SecurityConfig 等;仅在
ModuleService/ModuleServiceImpl/ModuleController/ModuleMapper上做增量。 -
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 锚点继续生效,无需新增锚点关键字)。 - 环检测策略:选择"递归向上查 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)
-
updateWithValidDto_invokesUpdateById_withEditableFieldsOnly— MockselectById返回非空、existsActiveById返回 true(若有 parent);断言传入updateById的 entity:iIncrement是路径 id;sProcedureName/sCreatedBy/tCreateDate/sBrandsId/sSubsidiaryId全部为 null(NOT_NULL 策略跳过);可改字段被透传。 -
updateWithTargetNotFound_throws40400— MockselectById返回 null;不调updateById。 -
updateWithInvalidDisplayType_throws40010— DTOsDisplayType="未知";不调updateById。 -
updateWithSelfParentId_throws40021— DTOiParentId == path id;错误信息含"自身"。 -
updateWithMissingParent_throws40021— MockexistsActiveById(parent) → false;错误信息含"父模块不存在"。 -
updateWithCyclicParent_throws40021— 构造 mapper 行为:existsActiveById(parent)=true;递归向上selectById(parent).getIParentId() == path id;期望抛 40021,错误信息含"环路"。 -
updateWithBShowPermissionNull_setsFalseInEntity— DTObShowPermission=null;entity 字段为false。
集成测试(追加到 ModuleControllerIT)
-
putValidBody_with_jwt_returns200_andUpdatesEditableFields— 先 INSERT 一条原始行,再 PUT;查 DB:可改字段为新值,sProcedureName/sCreatedBy保持原值。 -
putNonExistentId_returns40400— PUT/api/mod/modules/99999999;code=40400。 -
putInvalidDisplayType_returns40010—code=40010。 -
putSelfParent_returns40021— bodyiParentId == path id;code=40021。 -
putCyclicParent_returns40021— 准备数据:root → child;PUT root 把iParentId改成 child(构成环);code=40021。 -
putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy— 先 INSERT(通过 POST 接口或 JdbcTemplate),再无 token PUT;sCreatedBy仍是原值(不被覆盖为 STUB_ADMIN)。 -
putTamperedJwt_returns20001—code=20001。
工程验收
-
cd backend && mvn -B test全绿(含 MOD-001 已有 26 用例 + 本 REQ 新增至少 7+7=14 用例,总 ≥ 40 用例) - SecurityConfig 路径规则更新后,MOD-001 已有 IT 仍 PASS(permitAll 范围扩大不收紧)
- DB 中
sProcedureName在更新前后字面相同(验证未被覆盖)