REQ-USR-002 修改用户 — 任务级 TDD 计划(后端)
阶段:后端(backend)。作用域:
backend/**(controller / service / service.impl / mapper / DTO / 校验 / REST 契约实现)。禁止写frontend/**。 上游 SSoT:specdocs/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;不 setsUserName/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;lintmvn -q -B checkstyle:check;unitmvn -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— 参数校验失败(字段格式 / 必填 / 枚举越界 /iEmployeeId或permissionIds元素不存在 /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-001createUser返回形状一致)。 - 复用既有:
SecurityUtil.currentUserType()→String;Result.success(T);BusinessException(ResultCode)/BusinessException(ResultCode, String)暴露getResultCode();Mapper 继承BaseMapper(selectById/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统一转40001。sUserType/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/审计/租户字段)。 - 验证:子会话跑
UpdateUserDTOValidationTestPASS。 - commit:
feat(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.updateById的UsrUser:iIncrement==id、上述列为新值、且sUserName/sPassword/sCreator/tCreateDate未被赋值(保持 null,证明不参与 SET,依赖 MP null 不更新语义)。 -
::nullOptionalColumnsAreNotOverwritten—— DTO 仅含必填sUserType/sLanguage,sUserNo/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);组装目标UsrUser仅setIIncrement(id)+ set 非 null 可更新列(sUserType/sLanguage必填总 set,sUserNo/iEmployeeId/iCanModifyBill/iIsVoid仅在 DTO 非 null 时 set);updateById。 - 验证:子会话跑上述 3 个用例 PASS。
- commit:
feat(usr): 修改用户主记录部分更新与存在性校验 REQ-USR-002
T3 — Service:关联职员存在性 + 权限组全量覆盖
- 测试:续
UsrUserServiceImplTest:-
::nonExistentEmployeeOnUpdateThrows40001—— 目标用户存在但 DTOiEmployeeId指向usrEmployeeMapper.selectById返回 null →BusinessException(PARAM_INVALID),不updateById、不动权限表(事务回滚语义)。 -
::nonExistentPermissionOnUpdateThrows40001——permissionIds含usrPermissionMapper.selectById返回 null 的 id →40001,不updateById、不写权限表。 -
::permissionIdsOverwriteDeletesThenInserts——permissionIds=[a,a,b](均存在)→ 先对usrUserPermissionMapper.delete(按iUserId=id)调用 1 次清旧授权,再去重批量insert2 行(id,a)/(id,b)(断言 delete 与 insert 次数/内容;用ArgumentCaptor验证iUserId==id、iPermissionId含 a/b 各一次)。 -
::emptyPermissionIdsClearsAll——permissionIds=[]→delete(按iUserId=id)被调用,insert不被调用(清空)。 -
::nullPermissionIdsLeavesGrantsUntouched——permissionIds=null→delete与insert均不被调用(不改动授权)。
-
- 实现:在
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。
- commit:
feat(usr): 修改用户关联职员校验与权限组全量覆盖 REQ-USR-002
T4 — Controller + 管理员权限前置
- 测试:
backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java(续既有类,MockMvc standaloneSetup + 真实GlobalExceptionHandler+ mockUsrUserService+ mockSecurityUtil静态):-
::adminUpdateReturnsCodeZeroWithId—— 管理员(currentUserType="超级管理员")PUT /api/usr/users/55带合法 body(sUserType/sLanguage合法)→ HTTP 200,code==0,data.id==55;响应体不含sPassword/password。 -
::nonAdminUpdateReturns40301—— 普通用户 →code==40301,usrUserService.updateUser不被调用。 -
::invalidBodyUpdateReturns40001—— body 缺sUserType(@NotBlank失败)→code==40001,Service 不被调用。 -
::userNotFoundReturns40401—— 管理员 + 合法 body,但usrUserService.updateUser抛BusinessException(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。 - commit:
feat(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/sUserNo→code=0、data.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_permissiona/b/c +usr_user_permission),PUT传permissionIds=[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/ 改sUserType后selectById读回库内iIsVoid==1/sUserType为新值"做后端落库层等价验证,登录/查询联动留待对应 REQ 的 IT 覆盖,在本测试注释中标注此边界。)
-
- 实现:仅在前序 task 暴露缺口时做最小修补(如 MP
updateById对 null 字段的实际行为与预期不符时调整组装策略、权限删除 wrapper 边界),不引入新公共契约、不新增 migration。 - 验证:子会话跑
UsrUserUpdateITPASS(连库);随后全量mvn -q -B test全绿、mvn -q -B checkstyle:check通过。 - commit:
test(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-001createUser返回形状一致。 -
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 复用既有
BaseMapper(selectById/updateById/delete/insert/selectCount)+UsrUserPermission(Integer,Integer)构造器,无新增 Mapper 方法签名。