2026-04-29-REQ-MOD-002.md 9.03 KB

req_id: REQ-MOD-002 date: 2026-04-29

module: module_mod

Spec: REQ-MOD-002 — 模块修改

目标

在不破坏唯一性的前提下,更新已有模块的可编辑字段:sDisplayType / sModuleType / sManageDeptEn / bShowPermission / sModuleNameZh / iParentId / iSortOrdersProcedureNamesCreatedBytCreateDatesBrandsId / 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=20001sCreatedBy 在更新时不修改,无论是否携带 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。

业务规则

  1. 目标存在性:先 SELECT * FROM tModule WHERE iIncrement = {id} AND bDeleted = 0;找不到 → BizException(40400, "模块不存在或已删除")
  2. 枚举校验sDisplayTypeDISPLAY_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,目前会落 handleAnycode=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

  • updateWithValidDto_invokesUpdateById_withEditableFieldsOnly — Mock selectById 返回非空、existsActiveById 返回 true(若有 parent);断言传入 updateById 的 entity:iIncrement 是路径 id;sProcedureName / sCreatedBy / tCreateDate / sBrandsId / sSubsidiaryId 全部为 null(NOT_NULL 策略跳过);可改字段被透传。
  • updateWithTargetNotFound_throws40400 — Mock selectById 返回 null;不调 updateById
  • updateWithInvalidDisplayType_throws40010 — DTO sDisplayType="未知";不调 updateById
  • updateWithSelfParentId_throws40021 — DTO iParentId == path id;错误信息含"自身"。
  • updateWithMissingParent_throws40021 — Mock existsActiveById(parent) → false;错误信息含"父模块不存在"。
  • updateWithCyclicParent_throws40021 — 构造 mapper 行为:existsActiveById(parent)=true;递归向上 selectById(parent).getIParentId() == path id;期望抛 40021,错误信息含"环路"。
  • updateWithBShowPermissionNull_setsFalseInEntity — DTO bShowPermission=null;entity 字段为 false

集成测试(追加到 ModuleControllerIT

  • putValidBody_with_jwt_returns200_andUpdatesEditableFields — 先 INSERT 一条原始行,再 PUT;查 DB:可改字段为新值,sProcedureName / sCreatedBy 保持原值。
  • putNonExistentId_returns40400 — PUT /api/mod/modules/99999999code=40400
  • putInvalidDisplayType_returns40010code=40010
  • putSelfParent_returns40021 — body iParentId == path idcode=40021
  • putCyclicParent_returns40021 — 准备数据:root → child;PUT root 把 iParentId 改成 child(构成环);code=40021
  • putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy — 先 INSERT(通过 POST 接口或 JdbcTemplate),再无 token PUT;sCreatedBy 仍是原值(不被覆盖为 STUB_ADMIN)。
  • putTamperedJwt_returns20001code=20001

工程验收

  • cd backend && mvn -B test 全绿(含 MOD-001 已有 26 用例 + 本 REQ 新增至少 7+7=14 用例,总 ≥ 40 用例)
  • SecurityConfig 路径规则更新后,MOD-001 已有 IT 仍 PASS(permitAll 范围扩大不收紧)
  • DB 中 sProcedureName 在更新前后字面相同(验证未被覆盖)