2026-06-01-REQ-USR-002.md 21.6 KB

REQ-USR-002 修改用户 — 任务级 TDD 计划(后端)

阶段:后端(backend)。作用域:backend/**(controller / service / service.impl / mapper / DTO / 校验 / REST 契约实现)。禁止frontend/**。 上游 SSoT:spec docs/superpowers/specs/2026-06-01-REQ-USR-002.md;需求卡片 docs/01-需求清单/USR-用户管理/REQ-USR-002.md;DB 设计 docs/03-数据库设计文档.md;API 契约 docs/05-API接口契约.md;技术规范 docs/04-技术规范.md;配置 config-vars.yaml。 本计划告诉 TDD 执行者做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 本 REQ 复用 REQ-USR-001 已建的 backend 骨架与 modules/usr/**(controller / service / mapper / entity)、common/**(Result / ResultCode / BusinessException / GlobalExceptionHandler / SecurityUtil / JWT);新建公共基础设施,新增 migration。


Goal(目标)

实现后台管理员修改已有用户基本信息的唯一端点 PUT /api/usr/users/{id}:接收路径 id + UpdateUserDTO,前置管理员权限校验,校验目标用户存在、枚举/取值合法、关联职员与权限组 id 存在,按"部分更新(null 不改)"语义更新 usr_user(不动 sUserName / sPassword / 审计列 / 租户列),并按"全量覆盖"语义重写该用户在 usr_user_permission 的授权,整体单事务,返回 Result<{ id }>。变更立即生效(角色 / 禁用状态实时反映到查询与登录流程)。

Architecture(架构 / 分层)

遵循 docs/04 § 1.2,根包 com.xly.erp;本 REQ 仅触及 modules/usr/**,不跨模块、不动 common/**

backend/src/main/java/com/xly/erp/modules/usr/
├── controller/UsrUserController.java        # 既有类,新增 @PutMapping("/users/{id}") updateUser;仅 @Valid + 管理员前置 + 委派
├── service/UsrUserService.java              # 既有接口,新增 updateUser(Integer id, UpdateUserDTO dto)
├── service/impl/UsrUserServiceImpl.java     # 既有实现类,新增 updateUser 实现 @Transactional
├── mapper/UsrUserMapper.java                # 既有,复用 BaseMapper<UsrUser>(selectById/updateById/update)
├── mapper/UsrUserPermissionMapper.java      # 既有,复用 BaseMapper(delete by iUserId / insert)
├── mapper/UsrEmployeeMapper.java            # 既有,复用 selectById 做职员存在性校验
├── mapper/UsrPermissionMapper.java          # 既有,复用 selectById 做权限存在性校验
├── entity/{UsrUser,UsrUserPermission,UsrEmployee,UsrPermission}.java  # 既有,无需改
└── dto/UpdateUserDTO.java                    # 【本 REQ 新增】修改用户入参
  • 跨模块:无。本 REQ 全部落在 modules/usr/**,不动 common/**,不新增公共契约(错误码 40401/40301/40001 已在 REQ-USR-001 一次性建好的 ResultCode 枚举中预留,直接复用,不改 ResultCode)。
  • 数据访问:只走 Mapper(MyBatis-Plus);存在性校验 / 删授权用 LambdaQueryWrapper 或 MP 内置(selectById / selectCount / delete);Controller 禁止直接调 Mapper。
  • 部分更新落库策略:为实现"null 列不更新",更新主记录用 MP 的 updateById(MP 默认 null 字段不参与 SET,恰好匹配"传 null 不改该列"语义),目标实体只 set 非 null 的可更新列 + 主键 iIncrement;不 set sUserName / sPassword / sCreator / tCreateDate / 租户列,杜绝覆盖原值。

Tech Stack(技术栈,源自 docs/04 § 零 + config-vars)

  • Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus;MySQL 8.x;Flyway 10.x(启动时自动 apply sql/migrations/本 REQ 不新增 migration,复用 V1__initial_schema.sql,spec § 4 / D5)。
  • Spring Security + JWT(既有 JwtAuthenticationFilter / JwtUtil / SecurityUtil);本接口受保护(非登录端点,安全链已要求认证)。
  • 根包 com.xly.erp;端口与 DB 凭据 / JWT 密钥只读 config-vars.yaml / application.yml,不硬编码。
  • 命令(docs/04 § 零):build mvn -q -B -DskipTests package;lint mvn -q -B checkstyle:check;unit mvn -q -B test;e2e 无。

合同级常量(跨 task 必须一致)

  • REST:PUT /api/usr/users/{id}id@PathVariable Integer)。
  • 错误码(复用既有 ResultCode 枚举,spec § 6 / docs/05,不新增不修改枚举):
    • SUCCESS=0 — 成功,data.id = 被修改用户主键。
    • PARAM_INVALID=40001 — 参数校验失败(字段格式 / 必填 / 枚举越界 / iEmployeeIdpermissionIds 元素不存在 / id 非正整数)。
    • FORBIDDEN=40301 — 无权限(非管理员调用)。
    • NOT_FOUND=40401 — 用户不存在(路径 id 无对应 usr_user 记录)。
  • 管理员判定口径(与 REQ-USR-001 一致,spec § 8 D6):SecurityUtil.currentUserType() 等于常量 超级管理员 视为有权;否则抛 BusinessException(FORBIDDEN)。控制器内沿用既有 ADMIN_USER_TYPE = "超级管理员" 常量,不重复定义新口径。
  • 枚举取值:sUserType ∈ {普通用户, 超级管理员}sLanguage ∈ {中文, 英文, 繁体}iCanModifyBill ∈ {0,1}iIsVoid ∈ {0,1}(0 正常 / 1 禁用)。
  • 部分更新语义(spec § 8 D3):可选字段(sUserNo / iEmployeeId / iCanModifyBill / iIsVoid)传 null = 本次不改该列(保持原值);非 null 即覆盖。
  • 权限覆盖语义(spec § 8 D4):permissionIds 非 null = 以该集合(去重)全量覆盖该用户授权(先删该用户全部旧授权再批量插入去重后的目标集合,结果 = 目标集合);[] = 清空全部授权;null = 不改动现有授权。
  • 只读 / 不接收字段(spec § 2.1 / § 3):sUserName / sPassword / tCreateDate / sCreator / tLastLoginDate / iIncrement / sId / sBrandsId / sSubsidiaryId 一律不在本接口更新;UpdateUserDTO 不含这些字段(即便请求体携带也因 DTO 无该字段而被忽略)。

关键签名(首次出现处给出,跨 task 保持一致)

  • UsrUserService#updateUser(Integer id, UpdateUserDTO dto) 返回 Integer(被修改用户主键 iIncrement,等于入参 id)。
  • UsrUserController#updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto) 返回 Result<Map<String, Object>>data.id = 被修改主键,键名 id,与 REQ-USR-001 createUser 返回形状一致)。
  • 复用既有:SecurityUtil.currentUserType()StringResult.success(T)BusinessException(ResultCode) / BusinessException(ResultCode, String) 暴露 getResultCode();Mapper 继承 BaseMapperselectById / updateById / selectCount / delete / insert);UsrUserPermission(Integer iUserId, Integer iPermissionId) 构造器。
  • 实体 getter/setter 沿用匈牙利前缀风格(getIIncrement / setSUserType / setIIsVoid 等,已存在于 UsrUser / UsrUserPermission)。

DTO 形状(UpdateUserDTO,置于 modules/usr/dto

字段为匈牙利前缀命名(与列名一致),getter 形如 getSUserType 会被 Jackson 推断为属性名 SUserType,与契约 JSON 键不符;故对带前缀字段显式 @JsonProperty 锁定 JSON 键名(与 CreateUserDTO 同样做法)。

字段 类型 校验注解 语义
sUserNo String @Size(max=50) 可选;非 null 覆盖,null 不改
iEmployeeId Integer —(存在性在 Service 校验) 可选;非 null 须存在于 usr_employee 否则 40001;null 不改
sUserType String @NotBlank + `@Pattern(regexp="^(普通用户 超级管理员)$")`
sLanguage String @NotBlank + `@Pattern(regexp="^(中文 英文
iCanModifyBill Integer @Min(0) + @Max(1) 可选;非 0/1 越界 40001;null 不改
iIsVoid Integer @Min(0) + @Max(1) 可选;0 正常 / 1 禁用;非 0/1 越界 40001;null 不改
permissionIds List<Integer> —(元素存在性在 Service 校验) 可选;非 null 全量覆盖授权;[] 清空;null 不改

注:@Valid 失败由既有 GlobalExceptionHandler 统一转 40001sUserType / sLanguage 既由注解兜底,也在 Service 端不做额外越界放行;Service 对"目标用户不存在"抛 40401,对"关联 id 不存在"抛 40001。本 DTO 不含 sUserName / sPassword / 审计 / 租户字段。


任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟)

业务类 commit subject 必须带 REQ-USR-002 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。

T1 — UpdateUserDTO + Bean Validation

  • 测试backend/src/test/java/com/xly/erp/modules/usr/dto/UpdateUserDTOValidationTest.java(用 jakarta.validation.Validator 直接 validate DTO 断言 violations 数量):
    • ::acceptsMinimalValidBody —— 仅 {sUserType:"普通用户", sLanguage:"中文"}(其余可选字段全 null)无违反。
    • ::rejectsBlankUserType —— sUserType 为空白触发 @NotBlank
    • ::rejectsIllegalLanguage —— sLanguage="日文" 违反 @Pattern
    • ::rejectsOutOfRangeIsVoid —— iIsVoid=2 违反 @Max(1)::rejectsOutOfRangeCanModifyBill —— iCanModifyBill=2 违反 @Max(1)
  • 实现modules/usr/dto/UpdateUserDTO.java,按上「DTO 形状」加字段 + @JsonProperty + 校验注解 + getter/setter(不含 sUserName/sPassword/审计/租户字段)。
  • 验证:子会话跑 UpdateUserDTOValidationTest PASS。
  • commitfeat(usr): 修改用户入参 UpdateUserDTO 与校验 REQ-USR-002

T2 — Service:目标用户存在性 + 主记录部分更新(核心写)

  • 测试backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java(续既有类,Mockito mock 4 Mapper + PasswordEncoder + SecurityUtil 静态):
    • ::updateNonExistentUserThrows40401 —— usrUserMapper.selectById(id) 返回 null → 抛 BusinessException(NOT_FOUND),且不调用 updateById / 不动权限表。
    • ::updateAppliesNonNullColumnsAndKeepsIdentityImmutable —— 目标用户存在,DTO 设 sUserType="超级管理员" / sLanguage="英文" / iCanModifyBill=1 / iIsVoid=1 / sUserNo="N9",断言传给 usrUserMapper.updateByIdUsrUseriIncrement==id、上述列为新值、且 sUserName/sPassword/sCreator/tCreateDate 未被赋值(保持 null,证明不参与 SET,依赖 MP null 不更新语义)。
    • ::nullOptionalColumnsAreNotOverwritten —— DTO 仅含必填 sUserType/sLanguagesUserNo/iEmployeeId/iCanModifyBill/iIsVoid 均 null → 传给 updateById 的实体这些字段为 null(MP 不 SET,保持原值)。
  • 实现UsrUserService.java 新增 Integer updateUser(Integer id, UpdateUserDTO dto)UsrUserServiceImpl.java 新增实现并标 @Transactional(rollbackFor = Exception.class):先 selectById(id) 校验存在(null → 40401);组装目标 UsrUsersetIIncrement(id) + set 非 null 可更新列(sUserType/sLanguage 必填总 set,sUserNo/iEmployeeId/iCanModifyBill/iIsVoid 仅在 DTO 非 null 时 set);updateById
  • 验证:子会话跑上述 3 个用例 PASS。
  • commitfeat(usr): 修改用户主记录部分更新与存在性校验 REQ-USR-002

T3 — Service:关联职员存在性 + 权限组全量覆盖

  • 测试:续 UsrUserServiceImplTest
    • ::nonExistentEmployeeOnUpdateThrows40001 —— 目标用户存在但 DTO iEmployeeId 指向 usrEmployeeMapper.selectById 返回 null → BusinessException(PARAM_INVALID),不 updateById、不动权限表(事务回滚语义)。
    • ::nonExistentPermissionOnUpdateThrows40001 —— permissionIdsusrPermissionMapper.selectById 返回 null 的 id → 40001,不 updateById、不写权限表。
    • ::permissionIdsOverwriteDeletesThenInserts —— permissionIds=[a,a,b](均存在)→ 先对 usrUserPermissionMapper.delete(按 iUserId=id)调用 1 次清旧授权,再去重批量 insert 2 行 (id,a)/(id,b)(断言 delete 与 insert 次数/内容;用 ArgumentCaptor 验证 iUserId==idiPermissionId 含 a/b 各一次)。
    • ::emptyPermissionIdsClearsAll —— permissionIds=[]delete(按 iUserId=id)被调用,insert 不被调用(清空)。
    • ::nullPermissionIdsLeavesGrantsUntouched —— permissionIds=nulldeleteinsert 均不被调用(不改动授权)。
  • 实现:在 updateUser 内(主记录更新前先做存在性校验,保证非法即整体回滚不留副作用):iEmployeeId 非 null 时校验 usr_employee 存在(缺失 40001);permissionIds 非 null 时去重并逐个校验 usr_permission 存在(缺失 40001);主记录 updateById 成功后,若 permissionIds 非 null 则按 LambdaQueryWrapper.eq(UsrUserPermission::getIUserId, id) 全量删除旧授权,再对去重集合逐行 insert(new UsrUserPermission(id, permId))(空集合则只删不插);全程同一 @Transactional
  • 验证:子会话跑上述 5 个用例 PASS。
  • commitfeat(usr): 修改用户关联职员校验与权限组全量覆盖 REQ-USR-002

T4 — Controller + 管理员权限前置

  • 测试backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java(续既有类,MockMvc standaloneSetup + 真实 GlobalExceptionHandler + mock UsrUserService + mock SecurityUtil 静态):
    • ::adminUpdateReturnsCodeZeroWithId —— 管理员(currentUserType="超级管理员"PUT /api/usr/users/55 带合法 body(sUserType/sLanguage 合法)→ HTTP 200,code==0data.id==55;响应体不含 sPassword/password
    • ::nonAdminUpdateReturns40301 —— 普通用户 → code==40301usrUserService.updateUser 不被调用。
    • ::invalidBodyUpdateReturns40001 —— body 缺 sUserType@NotBlank 失败)→ code==40001,Service 不被调用。
    • ::userNotFoundReturns40401 —— 管理员 + 合法 body,但 usrUserService.updateUserBusinessException(NOT_FOUND)code==40401(验证 Controller 不吞业务异常、由全局处理器转码)。
  • 实现UsrUserController.java 新增 @PutMapping("/users/{id}") 方法 updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto):入口判定 !ADMIN_USER_TYPE.equals(SecurityUtil.currentUserType())BusinessException(FORBIDDEN)(先于业务校验,spec § 3.9);委派 usrUserService.updateUser(id, dto);返回 Result.success(Map.of("id", id))。Controller 不直接调 Mapper、不写业务逻辑。
  • 验证:子会话跑 UsrUserControllerTest(既有 + 新增用例)PASS。
  • commitfeat(usr): 修改用户 Controller 与管理员权限前置 REQ-USR-002

T5 — 端到端验收回归(按 spec § 7 验收标准收口)

  • 测试backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java@SpringBootTest + @AutoConfigureMockMvc + @ActiveProfiles("test"),连测试库 Flyway 已 apply V1;用真实 JwtUtil 签发 token 走安全链;@AfterEach 自清理本测试 fixture,命名前缀如 it2_user_ / IT2_PERM_ 便于 likeRight 清理;管理员 token 与普通用户 token 仿 UsrUserCreateIT 生成)覆盖 spec § 7:
    • ::ac1UpdateBasicInfoPersists —— 先经 POST /api/usr/users 建一个用户取 id(或直接 Mapper 插 fixture),再 PUT /api/usr/users/{id}sUserType/sLanguage/iCanModifyBill/sUserNocode=0data.id==id;库中该行上述列为新值,且 sUserName/sPassword/sCreator/tCreateDate 字节级不变(保存改前后 selectById 比对)。
    • ::ac2UpdateNonExistentReturns40401 —— PUT /api/usr/users/{很大且不存在的 id}code=40401,无任何写入。
    • ::ac3InvalidParamRollsBack —— iEmployeeId 指向不存在职员(或 permissionIds 含不存在 id)→ code=40001,目标用户行各列与调用前一致(无副作用)。
    • ::ac6PermissionOverwrite —— 预置该用户授权为 {a,c}(fixture 插 usr_permission a/b/c + usr_user_permission),PUTpermissionIds=[a,b,a] → 覆盖后该用户授权恰为 {a,b}(c 被删、b 新增、a 去重一次);另一用例或同用例追加:传 permissionIds=[] → 该用户授权清空;不传 permissionIds → 授权不变。
    • ::ac7NonAdminAndNoTokenBlocked —— 普通用户 token → code=40301;无 token → HTTP 401;两种情况下目标用户行均未被修改。
    • ::ac8PasswordUnchangedAndAbsentFromResponse —— 修改成功后 sPassword 列与改前字节级一致(selectById 比对哈希值相等);成功响应体仅含 data.id,不含 sPassword / 明文 / password 字段。
    • (AC4「禁用实时生效」、AC5「角色变更实时生效」依赖 REQ-USR-004 登录 / REQ-USR-003 查询接口,尚未实现;本 IT 以"PUT iIsVoid=1 / 改 sUserTypeselectById 读回库内 iIsVoid==1 / sUserType 为新值"做后端落库层等价验证,登录/查询联动留待对应 REQ 的 IT 覆盖,在本测试注释中标注此边界。)
  • 实现:仅在前序 task 暴露缺口时做最小修补(如 MP updateById 对 null 字段的实际行为与预期不符时调整组装策略、权限删除 wrapper 边界),不引入新公共契约、不新增 migration。
  • 验证:子会话跑 UsrUserUpdateIT PASS(连库);随后全量 mvn -q -B test 全绿、mvn -q -B checkstyle:check 通过。
  • committest(usr): 修改用户端到端验收回归 REQ-USR-002

自审

占位符扫描

  • 全文无 【人工填写】 / TBD / TODO / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记不在本后端 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体})继续,不阻塞。

Spec coverage(spec 每节 → task 映射)

  • § 1 Goal(唯一端点 PUT /api/usr/users/{id}sUserName 不可改、密码不改)→ T4(端点)+ T2(不动 sUserName/sPassword)+ 全部 task。
  • § 2.1 输入 / DTO 字段与校验 / 路径参数 id / Auth → T1(DTO 校验)+ T2/T3(Service 存在性与覆盖)+ T4(路径参数 + 管理员前置)。
  • § 2.1 忽略只读/不接收字段(sUserName/sPassword/审计/租户)→ T1(DTO 不含这些字段)+ T2(组装实体不 set 这些列)+ T5 AC8(密码不变)。
  • § 2.2 输出 Result<{id}>、不含敏感字段 → T4(Controller 组装)+ T5 AC8。
  • § 3.1 目标用户必须存在(40401,不写入)→ T2 + T5 AC2。
  • § 3.2 sUserName 不可改 → T1(DTO 无该字段)+ T2 + T5 AC1。
  • § 3.3 密码不改/不返回 → T2(不 set sPassword)+ T5 AC8。
  • § 3.4 用户类型必填 + 枚举约束(越界 40001)→ T1(@NotBlank+@Pattern)+ T4。
  • § 3.5 语言必填 + 枚举约束 → T1(@NotBlank+@Pattern)。
  • § 3.6 关联职员可选且需存在(40001)/ 部分更新语义 → T3 + T2(null 不改)。
  • § 3.7 作废/禁用实时生效(iIsVoid ∈ {0,1},越界 40001)→ T1(@Min/@Max)+ T2(落库)+ T5(落库层等价验证,登录联动留 REQ-USR-004)。
  • § 3.8 权限组全量覆盖(permissionIds 非 null 覆盖 / [] 清空 / null 不改 / 去重 / 唯一索引)→ T3 + T5 AC6。
  • § 3.9 权限校验前置(非管理员 40301,先于业务)→ T4。
  • § 3.10 审计字段只读、不新增列/migration → T2(不 set 审计列)+ Tech Stack(不新增 migration)。
  • § 4 约束(分层/包路径/命名 updateUser/统一响应/异常/事务/认证/数据访问/配置/schema)→ T1-T4 分层落位,事务在 T2/T3,schema 复用 V1。
  • § 5 Schema 引用(写 usr_user/usr_user_permission、读 usr_employee/usr_permission)→ T2/T3(复用既有实体/Mapper)。
  • § 6 错误码(0/40001/40401/40301)→ 复用既有 ResultCode,T2(40401)/T3(40001)/T4(40301)。
  • § 7 验收标准 1-8 → T5(AC1/2/3/6/7/8 直接覆盖;AC4/AC5 以落库层等价验证 + 注释边界,联动留对应 REQ)。
  • § 8 decisions(D1-D6)→ D1(不改密码)T2、D2(iIsVoid 承载状态)T1/T2、D3(null 不改)T2、D4(全量覆盖)T3、D5(不新增 migration)Tech Stack、D6(管理员口径)T4 已体现。

类型一致性

  • UsrUserService#updateUser(Integer id, UpdateUserDTO):Integer 在 T2 定义,T3(实现续写)/T4(Controller 调用)/T5(IT)一致引用。
  • UsrUserController#updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO) 返回 Result<Map<String,Object>>data.id),与 REQ-USR-001 createUser 返回形状一致。
  • UpdateUserDTO 字段与校验注解在 T1 锁定,T2/T3/T4/T5 一致使用;字段命名与 UsrUser 列名匈牙利前缀一致,@JsonProperty 锁 JSON 键(与 CreateUserDTO 同风格)。
  • 错误码字面量 0/40001/40301/40401 复用既有 ResultCode,与 docs/05、spec § 6 一致,不新增枚举常量。
  • REST 路径 PUT /api/usr/users/{id}、管理员口径 超级管理员 与 docs/05、spec、既有 UsrUserController.ADMIN_USER_TYPE 一致。
  • Mapper 复用既有 BaseMapperselectById/updateById/delete/insert/selectCount)+ UsrUserPermission(Integer,Integer) 构造器,无新增 Mapper 方法签名。