From fb750522926bf8fabfae25e078418e38a77094fc Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 15 May 2026 09:57:36 +0800 Subject: [PATCH] chore(usr): REQ-USR-003 review approve + 归档 spec/plan/review --- docs/05-API接口契约.md | 3 ++- docs/08-模块任务管理.md | 2 +- docs/superpowers/plans/2026-05-15-REQ-USR-003.md | 362 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/reviews/2026-05-15-REQ-USR-003.md | 31 +++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-05-15-REQ-USR-003.md | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-15-REQ-USR-003.md create mode 100644 docs/superpowers/reviews/2026-05-15-REQ-USR-003.md create mode 100644 docs/superpowers/specs/2026-05-15-REQ-USR-003.md diff --git a/docs/05-API接口契约.md b/docs/05-API接口契约.md index d988e0e..406e12b 100644 --- a/docs/05-API接口契约.md +++ b/docs/05-API接口契约.md @@ -105,11 +105,12 @@ BasePath: `/api/v1` #### 错误码 - `40001` — 字段格式错误或试图修改 username / password +- `40004` — 指定的员工 / 权限分类不存在 +- `40101` — 未携带或无效 Token - `40301` — 当前用户非超级管理员,无权调用 - `40302` — 试图停用当前登录用户自己 - `40401` — 用户不存在 - `40902` — 用户号已被占用 -- `40004` — 指定的员工 / 权限分类不存在 ### REQ-USR-004 查询用户 diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index 383d489..fb78b84 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -62,7 +62,7 @@ - 功能: - [x] REQ-USR-001 用户登录 - [x] REQ-USR-002 新增用户 - - [ ] REQ-USR-003 修改用户 + - [x] REQ-USR-003 修改用户 - [ ] REQ-USR-004 查询用户 ## 三、Coding 阶段(前端整体) diff --git a/docs/superpowers/plans/2026-05-15-REQ-USR-003.md b/docs/superpowers/plans/2026-05-15-REQ-USR-003.md new file mode 100644 index 0000000..1998f61 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-REQ-USR-003.md @@ -0,0 +1,362 @@ +--- +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-tdd` executes 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.java` +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java` +- `backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java` +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java` +- `backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java` +- `backend/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.java` +- `backend/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 + errors: 40101, 40301, 40401 + +PUT /api/v1/users/{userId} @RequireSuperAdmin + body: UpdateUserReq (PATCH) + → Result + 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 permissionCategoryIds, + String updatedBy, LocalDateTime updatedDate +} + +UpdateUserReq { + String userCode, // 缺省/null = 不变 + String userType, // 同上 + String language, // 同上 + Boolean canEditDocument, // 同上 + Integer employeeId, // 缺省/null = 不变;0 = 解除关联;正整数 = 更新 + Boolean isDeleted, // 同上 + List 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 = 40302` +- `ErrorCode.USER_NOT_FOUND = 40401` +- `ErrorCode.toHttpStatus(40302) == 403` +- `ErrorCode.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) : boolean` +- SQL: `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` + - SQL: `SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}` +- `SysUserPermissionCategoryMapper#deleteByUserAndCategoryIds(@Param("userId") Integer userId, @Param("ids") List categoryIds) : int` + - SQL: `DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN (...)` (@Select script) + +- [ ] **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_employeeNameIsNull` + - `getById_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 路径覆盖: +1. `userId` 不存在 → 40401 +2. `req.isDeleted == true && userId == operatorUserId` → 40302 +3. `req.userCode` 非 null 且 `existsByUserCodeExcludingId(userCode, userId)` → 40902 +4. `req.employeeId` 是正整数(非 0)且不存在或软删 → 40004 +5. `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().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_otherFieldsUnchanged` + - `update_userType_language_canEditDocument` + - `update_employeeId_positiveInteger_setsToValue` + - `update_employeeId_zero_setsToNull` + - `update_employeeId_unchanged_preservesOriginalValue`(缺省字段不变) + - `update_isDeleted_true_marksUserDeleted` + - `update_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` +- `@PutMapping("/{userId}") @RequireSuperAdmin update(@PathVariable Integer userId, @RequestBody @Valid UpdateUserReq req) : Result` +- PUT 调用 `userUpdateService.update(userId, req, LoginContext.current().userId(), LoginContext.current().username())` + +端到端测试(覆盖 spec § 验收 1-23): + +**GET(5 个)**: +- `get_existingUser_returns200_andFullVo` +- `get_unknownUser_returns404_40401` +- `get_normalUser_returns403_40301` +- `get_noAuthHeader_returns401_40101` +- `get_deletedUser_stillReturns200` + +**PUT(16 个)**: +- `put_updateUserCodeAndType_returns200` +- `put_updateEmployeeId_toAnotherEmployee` +- `put_updateEmployeeId_zero_clearsRelation` +- `put_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_40902` +- `put_userCodeUnchangedSameAsSelf_returns200` +- `put_selfDeactivate_returns403_40302` +- `put_unknownProperty_username_returns400_40001` +- `put_unknownProperty_password_returns400_40001` +- `put_unknownUserId_returns404_40401` +- `put_normalUser_returns403_40301` +- `put_emptyBody_only_updates_audit_fields` + +- [ ] **Step 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` | diff --git a/docs/superpowers/reviews/2026-05-15-REQ-USR-003.md b/docs/superpowers/reviews/2026-05-15-REQ-USR-003.md new file mode 100644 index 0000000..3e29614 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-15-REQ-USR-003.md @@ -0,0 +1,31 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-15 +round: 1 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-003 — round 1 + +## 结论 +approve + +## Must-fix +(无) + +## Nice-to-have + +- docs/05-API接口契约.md PUT § REQ-USR-003 错误码列表缺 40101(GET 段已列)— 本轮归档前已修 +- docs/superpowers/specs/2026-05-15-REQ-USR-003.md § 输入表行写 "显式 null 表示解除关联",与 § PATCH 语义实现细节的 "null = 不变;employeeId=0 = 解除关联" 约定矛盾;代码遵循后者,建议日后回填修订前者 +- backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java:52 — existsByUserCodeExcludingId 未过滤 iIsDeleted;如希望软删用户不参与唯一性,可补 `AND iIsDeleted=0`,但当前 spec 未要求 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java:108 — permissionCategoryIds 差集流程在 @Transactional 内无行锁;并发 PUT 同一用户可能出现交叉写入。建议未来引入 sys_user.iVersion 做乐观锁 +- backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java:127 — update_employeeId_positiveInteger_setsToValue 注释 '避开自我停用守卫' 误导(本测试不会触发守卫);建议改用合法对手用户或删除该注释 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java:78 — 复用 COMPANY_NOT_FOUND(40004)抛"权限分类不存在",常量名与 message 字面冲突;建议未来将 40004 重命名为更通用的 RELATED_ENTITY_NOT_FOUND 或拆分专用 code(跨模块技术债) + +## 反例 / 测试覆盖缺口 + +无功能或安全级 gap。spec 验收 1-22 全部映射到测试;验收 23(作废用户登录 40103)spec 明确声明属 REQ-USR-001 既有路径,符合约定。事务边界(@Transactional + BizException extends RuntimeException + Spring 默认回滚)正确;自我停用守卫精确;userCode 唯一性"自身同值跳过查询"优化有效;employee 三态(null/0/正整数)与 permissionCategoryIds 三态(缺省/空数组/非空)语义清晰且测试覆盖;作废即时生效由 JwtHandlerInterceptor 每请求重查保证;权限分类差集策略保留项 iIncrement 不变已用单测显式断言。N+1 仅出现在 UserDetailService(user + employee + pcIds 三查),单详情场景可接受。 + +## 总结 + +REQ-USR-003 实现严格对齐 spec / plan:GET + PUT 共用 UserDetailVo、PATCH 三态、permissionCategoryIds 增删差集、自我停用守卫、userCode 唯一性排除自身、作废即时生效、事务回滚全部正确实现,147 测试覆盖 spec 验收 1-22。仅有若干文档不一致与可读性建议,本轮归档前已补 docs/05 PUT 错误码 40101。无功能/安全/正确性阻塞。Approve。 diff --git a/docs/superpowers/specs/2026-05-15-REQ-USR-003.md b/docs/superpowers/specs/2026-05-15-REQ-USR-003.md new file mode 100644 index 0000000..2f1d7bb --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-REQ-USR-003.md @@ -0,0 +1,181 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-15 +module: module_usr +--- + +# Spec: REQ-USR-003 — 修改用户 + +## 目标 + +超级管理员对已有用户的非密码、非用户名字段做部分更新(PATCH 语义),并支持增量增删权限分类授权。同时提供 GET 详情接口供前端表单回显(与修改入参 / 返回字段同源)。 + +## 输入 / 触发 + +两个 HTTP 入口(均需 `Authorization: Bearer ` 且 `userType=SUPER_ADMIN`): + +### 1. `GET /api/v1/users/{userId}` 用户详情 + +Path:`userId: int`(命中 `sys_user.iIncrement`,否则 40401)。 + +无请求体。 + +### 2. `PUT /api/v1/users/{userId}` 修改用户 + +Path:`userId: int`。 + +**请求体 UpdateUserReq**(JSON,PATCH 语义;任一字段缺省视为不变): + +| 字段 | 类型 | 必填 | 校验 | +|---|---|---|---| +| `userCode` | string | 否 | 提供时 `@Size(max=50) @NotBlank`;若与其他用户的 sUserCode 冲突返 40902(当前用户自身的同值视为不变,跳过冲突判定)| +| `userType` | string | 否 | 提供时 `NORMAL` / `SUPER_ADMIN` | +| `language` | string | 否 | 提供时 `zh-CN` / `en-US` / `zh-TW` | +| `canEditDocument` | boolean | 否 | true / false | +| `employeeId` | int 或 null | 否 | 提供为非 null 时必须命中 `sys_employee.iIncrement` AND `iIsDeleted=0`,否则返 40004;显式传 `null` 表示解除关联,DB 写 NULL | +| `isDeleted` | boolean | 否 | true 表示作废 / false 表示恢复启用;尝试停用当前登录用户自己返 40302 | +| `permissionCategoryIds` | int[] | 否 | 提供时按差集做增删;每个元素必须命中 `sys_permission_category.iIncrement AND iIsDeleted=0`,否则返 40004;缺省表示权限分类不变 | + +**严禁字段**:请求体含 `username` 或 `password` 字段直接返 40001(Jackson `fail-on-unknown-properties=true` 已在 REQ-USR-002 启用全局,缺省命中此防御)。 + +### permissionCategoryIds 增删差集策略 + +- 设当前已授权集合 `current = {pcId1, pcId2, ...}`(从 `sys_user_permission_category WHERE iUserId=?` 查) +- 设请求集合 `target = req.permissionCategoryIds` +- `toRemove = current \ target` → DELETE FROM sys_user_permission_category WHERE iUserId=? AND iPermissionCategoryId IN (toRemove) +- `toAdd = target \ current` → INSERT 对应行,`sGrantedBy = 当前登录 username` +- 同 `target ∩ current` 项保持不动(iIncrement / tCreateDate 不变) + +## 输出 / 结果 + +两个接口共用 `UserDetailVo`: + +```json +{ + "userId": 42, + "username": "alice", + "userCode": "U001", + "userType": "NORMAL", + "language": "zh-CN", + "canEditDocument": false, + "isDeleted": false, + "employeeId": 7, + "employeeName": "张三", + "permissionCategoryIds": [1, 2], + "updatedBy": "admin", + "updatedDate": "2026-05-15T09:30:00" +} +``` + +- `employeeName` 通过 `iEmployeeId` JOIN `sys_employee.sEmployeeName`;未关联职员时省略 +- `permissionCategoryIds` 是当前授权集合(增删后的最终状态);空数组表示无授权 +- `updatedBy` / `updatedDate` 由本接口在更新时写入(`sUpdatedBy` / `tUpdatedDate`);GET 取 DB 现有值,可能为 null + +**成功 200 OK**:`Result` + +**失败**: + +| HTTP | code | 含义 | 触发条件 | +|---|---|---|---| +| 400 | 40001 | 请求体格式错误 / 含未知字段(username/password) | jakarta 校验 OR Jackson fail-on-unknown | +| 400 | 40004 | 员工或权限分类不存在 | employeeId / permissionCategoryIds 校验失败 | +| 401 | 40101 | 未携带或无效 Token | 鉴权层 | +| 403 | 40301 | 非超级管理员调用 | 角色守卫 | +| 403 | 40302 | 试图停用当前登录用户自己 | `req.isDeleted == true && userId == LoginContext.userId()` | +| 404 | 40401 | 用户不存在 | `userId` 不命中 `sys_user.iIncrement` | +| 409 | 40902 | 用户号已被占用 | 提供的 userCode 命中其他用户的 sUserCode(排除自身) | + +## 业务规则 + +1. **鉴权 / 角色守卫**:复用 REQ-USR-002 的 JwtHandlerInterceptor + `@RequireSuperAdmin`。两个接口都标 `@RequireSuperAdmin`。 +2. **存在性校验**:先查 `sys_user.iIncrement = userId AND iIsDeleted ∈ {0,1}`(包含作废用户,因为恢复启用允许);找不到 → 40401。 +3. **自我停用守卫**(PUT 专属):`req.isDeleted == true && userId == LoginContext.current().userId()` → 40302。**注意**:恢复启用(`isDeleted == false`)即便针对自己也允许(不会有此场景,因为已登录用户必然非作废,但仍保留对称语义)。 +4. **userCode 唯一性**(PUT 专属):仅当 `req.userCode != null && !req.userCode.equals(currentUser.sUserCode)` 时才检查;用 `selectByUserCodeExcludingId(userCode, userId)` 排除自身。冲突 → 40902。 +5. **外键校验**:employeeId(非 null)和 permissionCategoryIds(非 null)按 REQ-USR-002 同样的方式校验。`employeeId == null` 显式表示解除关联(DB 置 NULL),与字段缺省不同(缺省 = 不变)。 +6. **部分更新**:只更新请求体中显式提供的字段;用 MyBatis-Plus `UpdateWrapper` 显式列出 set 项。`sUpdatedBy = LoginContext.current().username()`、`tUpdatedDate = NOW()` 一定写。 +7. **作废即时生效**:PUT 把 `iIsDeleted=1` 写库后,该用户已签发的 token 在下一次请求时会被 JwtHandlerInterceptor 检测到 iIsDeleted=1 并返 40101(已由 REQ-USR-002 基础设施保证)。 +8. **GET 详情**:聚合 sys_user + sys_employee(JOIN)+ sys_user_permission_category(IN 查询)一次返回;不查询作废过滤之外的额外字段。 + +## PATCH 语义实现细节 + +`UpdateUserReq` 用包装类型(Integer / Boolean / String / List)+ 自定义 `JsonNode` 检测"字段是否在 JSON 中出现"以区分"显式 null"与"缺省"。 + +具体方案: +- `userCode` / `userType` / `language` / `canEditDocument` / `isDeleted` / `permissionCategoryIds` — 缺省 = 不变;提供(非 null)= 更新;**不接受**显式 null(@NotNull 在 service 层不强制,但缺省即视为 null 表示"不变") +- `employeeId` — 三态:缺省(不变)/ 非 null 整数(更新)/ 显式 null(解除关联)。用 `JsonNullable`(来自 `openapi-generator` 工具库)实现三态。**最小可行替代**:在 controller 层用 `JsonNode` 解析 `employeeId` 字段,传给 service 一个 `EmployeeIdUpdate` 三态枚举(`UNCHANGED / SET(value) / UNSET`)。 + +> **简化决策**:本 REQ 不引入 `jakarta.json` 或 `JsonNullable` 第三方库(违反技术栈表)。采用如下约定: +> - 请求体仅当字段**存在且值非 null**时才更新;字段**完全缺省**视为不变;字段**显式 null** 视为不变(即不区分缺省与显式 null) +> - 单独提供"清除关联"语义:`employeeId == 0` 视为解除关联(DB 写 NULL)。这是一个约定(非业界标准 PATCH),spec 必须明示 + +实现简化后:`UpdateUserReq` 所有字段都是普通可空包装类型;`employeeId` 取值规则:null / 缺省 → 不变;`0` → 解除关联;正整数 → 更新到该 ID。 + +## 边界与约束 + +- **基础设施复用**:鉴权 / GlobalExceptionHandler / Result / BizException / LoginContext / JwtUtil / BCryptPasswordEncoder / SeederFixture 全部复用 REQ-USR-002 +- **ErrorCode 新增**:`USER_FORBIDDEN_SELF_DEACTIVATE = 40302`(HTTP 403)、`USER_NOT_FOUND = 40401`(HTTP 404)。`ErrorCode.toHttpStatus` 已含 401/403/404 段位映射,本 REQ 不需要新增映射。 +- **不实现**: + - 用户名 / 密码修改(推迟到独立 REQ) + - 批量修改(YAGNI) + - 修改历史审计表(推迟) + - GET 列表(REQ-USR-004 范围) + +## 依赖的 schema 表 / 字段 + +读 + 写 `sys_user`(V1 已建): +- 读:iIncrement / sUsername / sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sCreatedBy / sUpdatedBy / tUpdatedDate / tCreateDate(详情查询需要) +- 写:sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sUpdatedBy / tUpdatedDate + +读 + 写 `sys_user_permission_category`(V1 已建): +- 增删差集 + +只读 `sys_employee`(V1 已建): +- 校验 employeeId 存在 + 取 sEmployeeName + +只读 `sys_permission_category`(V1 已建): +- countActiveByIds 校验 + +**本 REQ 不需要新增 migration**。 + +## 依赖的接口 + +- 本 REQ 提供: + - `GET /api/v1/users/{userId}` — 用户详情 + - `PUT /api/v1/users/{userId}` — 修改用户 +- 前置依赖:JWT 由 REQ-USR-001 签发;JwtHandlerInterceptor 由 REQ-USR-002 提供 + +## 验收标准 + +后端集成测试: + +### GET 详情 + +1. **admin token + 存在用户** → 200,UserDetailVo 完整(含 employeeName + permissionCategoryIds) +2. **admin token + 不存在 userId** → 404 / 40401 +3. **NORMAL token** → 403 / 40301 +4. **无 Authorization** → 401 / 40101 +5. **作废用户** → 200(详情查询包含作废用户;不过滤) + +### PUT 修改 + +6. **修改 userCode + userType + language**(admin token)→ 200,DB 已更新;sUpdatedBy=admin、tUpdatedDate 非空;其他字段不变 +7. **修改 employeeId 为另一个有效员工** → 200,DB 写新值 +8. **修改 employeeId=0 解除关联** → 200,DB 写 NULL +9. **修改 employeeId=99999 不存在** → 400 / 40004 +10. **修改 isDeleted=true** → 200,DB 写 1;该用户原 token 下一次请求返 40101 +11. **修改 permissionCategoryIds(差集增删)**:初始权限 `[1,2]`,请求 `[2,3]` → 200,最终 DB 状态 `[2,3]`;分类 1 的行被 DELETE,分类 3 的行被 INSERT;分类 2 的行 iIncrement 保持不变(验证差集而非全量替换) +12. **permissionCategoryIds 为空数组** → 200,最终授权清空 +13. **permissionCategoryIds 含不存在 ID** → 400 / 40004,事务回滚(DB 授权无变化) +14. **修改 userCode 冲突(其他用户已用)** → 409 / 40902 +15. **修改 userCode 等于自身原值** → 200,无 40902 +16. **试图停用自己**(admin 用 admin token 改 admin 用户 isDeleted=true)→ 403 / 40302 +17. **请求体含 username 字段** → 400 / 40001 +18. **请求体含 password 字段** → 400 / 40001 +19. **userId 不存在** → 404 / 40401 +20. **NORMAL token 调用 PUT** → 403 / 40301 +21. **空请求体 `{}`** → 200,仅写 sUpdatedBy / tUpdatedDate(其他字段不变) + +### PUT + 立即可观察 + +22. **修改后调用 GET 详情** → 返回的字段反映 PUT 的写入值 +23. **作废用户尝试登录** → 401 / 40103(REQ-USR-001 既有路径,不在本 REQ 添加新测试,但成功路径验收应链路一致) -- libgit2 0.22.2