req_id: REQ-USR-003 date: 2026-05-15
spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-003.md
REQ-USR-003 修改用户 Implementation Plan
Execution: Parent skill
feature-tddexecutes this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: 实现 GET /api/v1/users/{userId} + PUT /api/v1/users/{userId} 两个端点;PUT 支持部分字段更新 + permissionCategoryIds 增量增删差集 + 自我停用守卫;GET 返回 UserDetailVo(含 employeeName + permissionCategoryIds)。
Architecture:
- 鉴权 / 角色守卫 / 事务 / Result / 异常处理全部复用 REQ-USR-002 基础设施。
- 新增
UserUpdateService(PUT)+UserDetailService(GET),单一职责。 - permissionCategoryIds 用差集:先 select 现有 → 计算 toAdd / toRemove → DELETE + INSERT 在同一事务。
- PATCH 语义简化:缺省 / 显式 null 都视为不变;
employeeId=0作为约定的"解除关联"信号。
Tech Stack: 复用 REQ-USR-002(Spring Boot 3 + MyBatis-Plus + Jakarta Validation)。
Schema 改动
无。
文件变更清单
新增:
backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.javabackend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.javabackend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.javabackend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.javabackend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.javabackend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java
修改:
-
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java(新增 40302 / 40401 + 404 映射) -
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java(新增existsByUserCodeExcludingId) -
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java(新增selectPermissionCategoryIdsByUserId+deleteByUserAndCategoryIds) -
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java(新增 GET + PUT 方法)
文档:本 REQ 不改 docs/05(已含 REQ-USR-003 段,但 docs/05 当前漏写 GET 详情接口;本 REQ 补充)。
测试:
backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.javabackend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java-
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java(独立文件避免冲击 REQ-USR-002 既有 UserControllerTest)
约束常量
ErrorCode 新增:
| 常量 | 值 | HTTP |
|---|---|---|
USER_FORBIDDEN_SELF_DEACTIVATE |
40302 |
403 |
USER_NOT_FOUND |
40401 |
404 |
ErrorCode.toHttpStatus已含 401/403/404 段位映射,不需新增。
API 形状:
GET /api/v1/users/{userId} @RequireSuperAdmin
→ Result<UserDetailVo>
errors: 40101, 40301, 40401
PUT /api/v1/users/{userId} @RequireSuperAdmin
body: UpdateUserReq (PATCH)
→ Result<UserDetailVo>
errors: 40001, 40004, 40101, 40301, 40302, 40401, 40902
UserDetailVo {
Integer userId, String username, String userCode,
String userType, String language, Boolean canEditDocument, Boolean isDeleted,
Integer employeeId, String employeeName,
List<Integer> permissionCategoryIds,
String updatedBy, LocalDateTime updatedDate
}
UpdateUserReq {
String userCode, // 缺省/null = 不变
String userType, // 同上
String language, // 同上
Boolean canEditDocument, // 同上
Integer employeeId, // 缺省/null = 不变;0 = 解除关联;正整数 = 更新
Boolean isDeleted, // 同上
List<Integer> permissionCategoryIds // 缺省/null = 不变;空数组 = 清空;非空 = 增删差集
}
PATCH 语义约定(写死,跨任务一致):所有字段缺省 / null 视为 "保持原值";employeeId == 0 视为 "解除关联",DB 写 NULL。其他字段无清除语义。
任务步骤
Task 1: ErrorCode 新增 40302 / 40401 + 404 映射 + docs/05 补 GET 详情段
Files:
- Modify:
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java - Modify:
backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java - Modify:
docs/05-API接口契约.md§ REQ-USR-003 之前加GET /api/v1/users/{userId}段
API shape:
ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE = 40302ErrorCode.USER_NOT_FOUND = 40401ErrorCode.toHttpStatus(40302) == 403ErrorCode.toHttpStatus(40401) == 404
docs/05 补充内容(追加到现 REQ-USR-003 段之前):
### REQ-USR-003 GET 用户详情
- Method: GET
- Path: /api/v1/users/{userId}
- Auth: Bearer Token;仅 userType=SUPER_ADMIN 可调用
- 请求: Path userId: int
- 响应: JSON UserDetailVo { userId, username, userCode, userType, language, canEditDocument, isDeleted, employeeId, employeeName, permissionCategoryIds[], updatedBy, updatedDate }
#### 错误码
- 40101 — 未携带或无效 Token
- 40301 — 当前用户非超级管理员,无权调用
- 40401 — 用户不存在
- Step 1: 写失败测试
ErrorCodeTest#httpMappings_coverNewCodes_v003 - Step 2: 实现最小代码 + docs/05 改动
- Step 3: 子会话验证 PASS
- Step 4: Commit
chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003
Task 2: UpdateUserReq + UserDetailVo
Files:
- Create:
backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java - Create:
backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java - Create:
backend/src/test/java/com/xly/erp/module/usr/dto/UpdateUserReqValidationTest.java
API shape: 见 "约束常量"。
校验注解(仅在字段非 null 时生效,jakarta @Pattern 等天然 null-safe):
-
userCode:@Size(max=50) -
userType:@Pattern(regexp="NORMAL|SUPER_ADMIN") -
language:@Pattern(regexp="zh-CN|en-US|zh-TW") -
employeeId:@Min(0)(0 = 解除关联约定) 其余字段无 @ 注解
-
Step 1: 写失败测试
UpdateUserReqValidationTest覆盖:- 全空请求体合法(PATCH,所有字段可选)
- userType 非枚举 → 失败
- language 非枚举 → 失败
- userCode 越长 → 失败
- userCode 空字符串 → 失败(@Size 不限定下界,但建议加 @Pattern("\S+") 防空白;本任务不强制)
- employeeId=-1 → 失败
- employeeId=0 合法(约定的解除关联)
Step 2: 实现最小代码
Step 3: 子会话验证 PASS
Step 4: Commit
feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003
Task 3: SysUserMapper.existsByUserCodeExcludingId
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java - Modify:
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java
API shape:
SysUserMapper#existsByUserCodeExcludingId(String userCode, Integer excludedUserId) : booleanSQL:
SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUserCode = #{userCode} AND iIncrement <> #{excludedUserId})-
Step 1: 写失败测试
-
existsByUserCodeExcludingId_otherUserHasCode_returnsTrue(alice U001, query U001 excluding admin → true) -
existsByUserCodeExcludingId_selfHasCode_returnsFalse(alice U001, query U001 excluding alice → false) existsByUserCodeExcludingId_unknownCode_returnsFalse
-
Step 2: 实现最小代码
Step 3: 子会话验证 PASS
Step 4: Commit
feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003
Task 4: SysUserPermissionCategoryMapper 增删辅助方法
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java - Create:
backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapperTest.java
API shape:
-
SysUserPermissionCategoryMapper#selectPermissionCategoryIdsByUserId(Integer userId) : List<Integer>- SQL:
SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}
- SQL:
-
SysUserPermissionCategoryMapper#deleteByUserAndCategoryIds(@Param("userId") Integer userId, @Param("ids") List<Integer> categoryIds) : int- SQL:
DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN (...)(@Select script)
- SQL:
-
Step 1: 写失败测试
- 准备:admin user + 授权 {PUR, SAL}
selectPermissionCategoryIdsByUserId_returnsAllCurrent-
deleteByUserAndCategoryIds_onlyDeletesGivenSubset(删 {PUR} 后剩 {SAL}) deleteByUserAndCategoryIds_nonMatchingIds_noop_returns0
Step 2: 实现最小代码
Step 3: 子会话验证 PASS
Step 4: Commit
feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003
Task 5: UserDetailService 接口 + 实现
Files:
- Create:
backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java - Create:
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java - Create:
backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java
API shape:
UserDetailService#getById(Integer userId) : UserDetailVo- 找不到用户 →
BizException(ErrorCode.USER_NOT_FOUND, "用户不存在") - 包含作废用户(用于恢复启用场景的回显)
装配逻辑:select sys_user → 若 iEmployeeId 非空 select sys_employee.sEmployeeName → selectPermissionCategoryIdsByUserId
-
Step 1: 写失败测试
-
getById_existingActiveUser_returnsFullVo(含 employeeName + permissionCategoryIds) getById_userWithoutEmployee_employeeNameIsNullgetById_userWithoutPermissions_emptyList-
getById_deletedUser_stillReturned(iIsDeleted=1 的用户也能查到,VO.isDeleted=true) getById_unknownId_throws40401
-
Step 2: 实现最小代码
Step 3: 子会话验证 PASS
Step 4: Commit
feat(usr): UserDetailService 用户详情查询 REQ-USR-003
Task 6: UserUpdateService 接口 + 校验骨架
Files:
- Create:
backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java - Create:
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java - Create:
backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java
API shape:
UserUpdateService#update(Integer userId, UpdateUserReq req, Integer operatorUserId, String operatorUsername) : UserDetailVo@Transactional- 注入:SysUserMapper / SysEmployeeMapper / SysPermissionCategoryMapper / SysUserPermissionCategoryMapper / UserDetailService(复用详情装配返回最终 VO)
本 Task 范围:校验路径(不写入),全部抛 BizException 路径覆盖:
-
userId不存在 → 40401 -
req.isDeleted == true && userId == operatorUserId→ 40302 -
req.userCode非 null 且existsByUserCodeExcludingId(userCode, userId)→ 40902 -
req.employeeId是正整数(非 0)且不存在或软删 → 40004 -
req.permissionCategoryIds非 null 且非空且含不存在 ID → 40004
- Step 1: 写失败测试 5 个测试覆盖以上路径
- Step 2: 实现最小代码(更新方法体仅 throw,不写 DB)
- Step 3: 子会话验证 PASS
- Step 4: Commit
feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003
Task 7: UserUpdateService 部分字段写入 + employee 三态
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java - Modify:
backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java
API behavior:
- 校验全过后用
UpdateWrapper<SysUser>().eq("iIncrement", userId)链式 set:-
if (req.userCode != null)→ set sUserCode -
if (req.userType != null)→ set sUserType -
if (req.language != null)→ set sLanguage -
if (req.canEditDocument != null)→ set iCanEditDocument (true → 1, false → 0) -
if (req.employeeId != null):req.employeeId == 0 → set iEmployeeId NULL;正整数 → set iEmployeeId 值 -
if (req.isDeleted != null)→ set iIsDeleted (true → 1, false → 0) - 总是 set sUpdatedBy=operatorUsername、tUpdatedDate=NOW()
-
不处理 permissionCategoryIds(推到 Task 8)
-
Step 1: 写失败测试
update_userCode_only_persisted_otherFieldsUnchangedupdate_userType_language_canEditDocumentupdate_employeeId_positiveInteger_setsToValueupdate_employeeId_zero_setsToNull-
update_employeeId_unchanged_preservesOriginalValue(缺省字段不变) update_isDeleted_true_marksUserDeletedupdate_isDeleted_false_revivesUser-
update_emptyRequest_onlyUpdatesAuditFields(sUpdatedBy + tUpdatedDate)
Step 2: 实现最小代码
Step 3: 子会话验证 PASS
Step 4: Commit
feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003
Task 8: UserUpdateService permissionCategoryIds 增量差集
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java - Modify:
backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java
API behavior:
- 仅当
req.permissionCategoryIds != null时执行:-
current = selectPermissionCategoryIdsByUserId(userId)(Set 化以便比较) -
target = new HashSet<>(req.permissionCategoryIds)(dedup) -
toRemove = current \ target→deleteByUserAndCategoryIds(userId, toRemove)(若 toRemove 非空) -
toAdd = target \ current→ 循环 insert SysUserPermissionCategory(sGrantedBy=operator)
-
返回
userDetailService.getById(userId)作为响应(聚合最新状态)-
Step 1: 写失败测试
-
update_permissionCategories_emptyList_clearsAll(初始 {PUR,SAL} → 请求 [] → 最终 ∅) -
update_permissionCategories_subsetDelta_addsAndRemoves(初始 {PUR,SAL} → 请求 [SAL, 新分类 X] → 最终 {SAL, X});断言 SAL 行 iIncrement 不变(差集而非全量替换) -
update_permissionCategories_omitted_preservesExisting(请求中无 permissionCategoryIds → 不动) -
update_permissionCategories_duplicateInRequest_deduped(请求 [PUR, PUR, SAL] → 最终 {PUR, SAL}) update_returnsUserDetailVoReflectingFinalState
-
Step 2: 实现最小代码
Step 3: 子会话验证 PASS
Step 4: Commit
feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003
Task 9: UserController GET + PUT + 端到端测试
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java(追加 GET + PUT 方法) - Create:
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java
API shape:
@GetMapping("/{userId}") @RequireSuperAdmin getById(@PathVariable Integer userId) : Result<UserDetailVo>@PutMapping("/{userId}") @RequireSuperAdmin update(@PathVariable Integer userId, @RequestBody @Valid UpdateUserReq req) : Result<UserDetailVo>- PUT 调用
userUpdateService.update(userId, req, LoginContext.current().userId(), LoginContext.current().username())
端到端测试(覆盖 spec § 验收 1-23):
GET(5 个):
get_existingUser_returns200_andFullVoget_unknownUser_returns404_40401get_normalUser_returns403_40301get_noAuthHeader_returns401_40101get_deletedUser_stillReturns200
PUT(16 个):
put_updateUserCodeAndType_returns200put_updateEmployeeId_toAnotherEmployeeput_updateEmployeeId_zero_clearsRelationput_updateEmployeeId_unknown_returns400_40004-
put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall(修改后用旧 token 调 GET 应得 40101) -
put_permissionCategories_subsetDelta(断言差集行为) put_permissionCategories_emptyArray_clearsAll-
put_permissionCategories_unknownId_returns400_40004_andRollsBack(断言事务回滚——sys_user 也未被改) put_duplicateUserCode_returns409_40902put_userCodeUnchangedSameAsSelf_returns200put_selfDeactivate_returns403_40302put_unknownProperty_username_returns400_40001put_unknownProperty_password_returns400_40001put_unknownUserId_returns404_40401put_normalUser_returns403_40301put_emptyBody_only_updates_audit_fieldsStep 1: 写失败测试 (21 个测试)
Step 2: 实现最小代码(在现有 UserController 上追加方法)
Step 3: 子会话验证 PASS
Step 4: Commit
feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003
提交计划
| Task | Commit message |
|---|---|
| 1 | chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003 |
| 2 | feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003 |
| 3 | feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003 |
| 4 | feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003 |
| 5 | feat(usr): UserDetailService 用户详情查询 REQ-USR-003 |
| 6 | feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003 |
| 7 | feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003 |
| 8 | feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003 |
| 9 | feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003 |