Commit 06d4c5a41b4ff7627ac8b1c8bd510bc183cf0297

Authored by zichun
1 parent e5923022

docs(usr): spec + plan REQ-USR-002

docs/superpowers/plans/2026-04-30-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-04-30
  4 +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-002.md
  5 +---
  6 +
  7 +# REQ-USR-002 用户修改 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 在 USR-001 已建工程基础上增量实现 `PUT /api/usr/users/{id}`:更新可编辑字段 + 重建权限组(删旧 + 插新);保留 `sPasswordHash` / `sCreatedBy` / 标准列。
  12 +
  13 +**Architecture:** 复用 `UserService` / `UserServiceImpl` / `UserController` / 全部 mappers;新增 `UpdateUserDTO`、`UserService#update`、`UserPermissionMapper#deleteByUserId`。SecurityConfig 已对 `/api/usr/**` permitAll,无需改。
  14 +
  15 +**Tech Stack:** 沿用(Spring Boot 3.3.5 / MyBatis-Plus / JJWT)。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(仅 UPDATE / DELETE / INSERT)。
  22 +
  23 +## 文件变更清单
  24 +
  25 +### 新增
  26 +
  27 +- `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java`
  28 +
  29 +### 修改
  30 +
  31 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` — 追加 `deleteByUserId(Integer)`
  32 +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `Integer update(Integer id, UpdateUserDTO dto)`
  33 +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update(含 5 类校验 + UPDATE + DELETE + INSERT × N)
  34 +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 PUT 端点
  35 +- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 10 单测
  36 +- `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` — 追加 1 用例(deleteByUserId)
  37 +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 10 IT
  38 +
  39 +## 任务步骤
  40 +
  41 +### Task 1: UserPermissionMapper#deleteByUserId + IT
  42 +
  43 +**Files:**
  44 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java`
  45 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java`
  46 +
  47 +**API shape:**
  48 +- `@Delete("DELETE FROM tUserPermission WHERE iUserId = #{userId}")` `int deleteByUserId(@Param("userId") Integer userId)`
  49 +
  50 +- [ ] **Step 1: 写失败测试 `userPermissionMapper#deleteByUserId_removesAllRowsForGivenUser`**
  51 + - 准备:插 user1 + user2;user1 关联 cat1+cat2,user2 关联 cat1
  52 + - 调 `deleteByUserId(user1.id)` → 返回 2,user1 行数 = 0;user2 行数 = 1(不受影响)
  53 +- [ ] **Step 2: 实现 mapper 方法**
  54 +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserMapperIT`
  55 +- [ ] **Step 4: Commit**:`feat(usr): mapper#deleteByUserId for permission rebuild REQ-USR-002`
  56 +
  57 +### Task 2: UpdateUserDTO + UserService.update 主流程(合法 + 目标存在性 + bShowPerm null→false)
  58 +
  59 +**Files:**
  60 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java`
  61 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  62 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  63 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  64 +
  65 +**API shape:**
  66 +- `UpdateUserDTO` 字段(与 CreateUserDTO 平行,去掉 `permissionCategoryIds` 之外字段语义不变;剔除 `sPasswordHash`,**仍包含** `permissionCategoryIds` 用于重建权限组)
  67 +- `UserService#update(Integer id, UpdateUserDTO dto) : Integer`
  68 +- 实现:
  69 + 1. `selectById(id)` → null 或 bDeleted=true → 40400
  70 + 2. 枚举校验 sUserType / sLanguage(同 create)→ 40001
  71 + 3. iStaffId 校验(同 create)→ 40022
  72 + 4. permissionCategoryIds 校验(仅当非空 list;null/空 list 跳过校验直接清空)→ 40023
  73 + 5. 构造 entity 仅 set iIncrement + 6 个可编辑字段(其余 null);`bCanModifyDocs` null → false
  74 + 6. `userMapper.updateById(entity)` try/catch DuplicateKeyException → 40020
  75 + 7. `userPermissionMapper.deleteByUserId(id)`
  76 + 8. 若 permissionCategoryIds 非空:for-loop 插 UserPermission
  77 +
  78 +- [ ] **Step 1: 追加 4 单测**
  79 + - `updateWithValidDto_invokesUpdateById_andRebuildsPermissions`
  80 + - `updateWithTargetNotFound_throws40400`
  81 + - `updateWithTargetAlreadyDeleted_throws40400`
  82 + - `updateWithBCanModifyDocsNull_setsFalseInEntity`
  83 +- [ ] **Step 2: 实现 DTO + service 主流程**
  84 +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserServiceImplTest`(期望 8+4=12)
  85 +- [ ] **Step 4: Commit**:`feat(usr): user update dto + service happy path REQ-USR-002`
  86 +
  87 +### Task 3: Service 异常分支补全(枚举 / staff / permission / 唯一冲突 / 清空权限)
  88 +
  89 +**Files:**
  90 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  91 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  92 +
  93 +**API shape:** 不变
  94 +
  95 +- [ ] **Step 1: 追加 6 单测**
  96 + - `updateWithInvalidUserType_throws40001`
  97 + - `updateWithInvalidLanguage_throws40001`
  98 + - `updateWithStaffNotFound_throws40022`
  99 + - `updateWithSomeInvalidPermissionIds_throws40023`
  100 + - `updateWithDuplicateUserNo_throws40020`
  101 + - `updateWithEmptyPermissionIds_clearsExisting` — permissionCategoryIds=null;deleteByUserId 调一次、insert 永不调
  102 +- [ ] **Step 2: 实现校验分支**
  103 +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserServiceImplTest`(期望 12+6=18)
  104 +- [ ] **Step 4: Commit**:`feat(usr): user update error branches REQ-USR-002`
  105 +
  106 +### Task 4: Controller PUT + IT(10 用例)+ 全量回归
  107 +
  108 +**Files:**
  109 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  110 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`
  111 +
  112 +**API shape:**
  113 +- `@PutMapping("/users/{id}") public Result<Map<String,Object>> update(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto)`
  114 +- 返回 `Result.ok(Map.of("iIncrement", userService.update(id, dto)))`
  115 +
  116 +- [ ] **Step 1: 追加 10 IT**(参 spec 验收清单)
  117 +- [ ] **Step 2: 实现 controller PUT**
  118 +- [ ] **Step 3: 子会话跑全量回归**:`mvn -B test`(期望 89 + 1+10+10=20 = 109+ 用例全绿)
  119 +- [ ] **Step 4: Commit**:`test(usr): user update integration coverage REQ-USR-002`
  120 +
  121 +## 提交计划
  122 +
  123 +| commit | 覆盖 |
  124 +|---|---|
  125 +| `feat(usr): mapper#deleteByUserId for permission rebuild REQ-USR-002` | Task 1 |
  126 +| `feat(usr): user update dto + service happy path REQ-USR-002` | Task 2 |
  127 +| `feat(usr): user update error branches REQ-USR-002` | Task 3 |
  128 +| `test(usr): user update integration coverage REQ-USR-002` | Task 4 |
... ...
docs/superpowers/specs/2026-04-30-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-04-30
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-002 — 用户修改
  8 +
  9 +## 目标
  10 +
  11 +在不破坏唯一性的前提下,更新已有用户的可编辑字段(`sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs`)+ 全量重建用户的权限组关联(`tUserPermission`)。`sPasswordHash`、`tCreateDate`、`sCreatedBy`、`sBrandsId` / `sSubsidiaryId`、软删除字段一律保留原值。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +### HTTP 接口(docs/05 § REQ-USR-002)
  16 +
  17 +- Method / Path: `PUT /api/usr/users/{id}`(path `{id}` = `tUser.iIncrement`)
  18 +- Auth: 必需(沿用 USR-001 stub:路径已在 SecurityConfig `/api/usr/**` permitAll,USR-004 闭环时统一改为 `hasAuthority('SUPER_ADMIN')`)
  19 +
  20 +### 请求 DTO `UpdateUserDTO`
  21 +
  22 +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 |
  23 +|---|---|---|---|---|
  24 +| `sUserNo` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_no`);冲突 → `40020` |
  25 +| `sUserName` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_name`);冲突 → `40020` |
  26 +| `iStaffId` | `Integer` | 否 | — | 非 null 时必须命中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40022`(沿用 USR-001 错误码语义;docs/05 § USR-002 未单列 40022,本实现复用 USR-001 已建立的语义) |
  27 +| `sUserType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[普通用户, 超级管理员]` 内;非法 → `40001` |
  28 +| `sLanguage` | `String` | 是 | `@NotBlank` | 必须在枚举 `[zh, en, zh-TW]` 内;非法 → `40001` |
  29 +| `bCanModifyDocs` | `Boolean` | 否 | — | 缺省 `false` |
  30 +| `permissionCategoryIds` | `List<Integer>` | 否 | — | 非空时所有 id 必须在 `tPermissionCategory` 中存在 + `bDeleted=0`;任一不合法 → `40023`(同 USR-001)。null / 空 list → 清空该用户权限组(删全部 tUserPermission) |
  31 +
  32 +> **`sPasswordHash` 显式从 DTO 中剔除**——API 契约声明该字段不可改;密码修改走独立接口(未来 REQ)。
  33 +
  34 +### 鉴权与上下文
  35 +
  36 +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 → `code=20001`;缺失 → permitAll 透传。`sCreatedBy` 在更新时**不修改**,无论是否携带 token。
  37 +
  38 +## 输出 / 结果
  39 +
  40 +### 成功响应
  41 +
  42 +```json
  43 +{ "code": 0, "msg": "ok", "data": { "iIncrement": 456 } }
  44 +```
  45 +
  46 +### 持久化效果
  47 +
  48 +事务内三步:
  49 +
  50 +1. UPDATE `tUser` SET `<可编辑列>` WHERE `iIncrement = {id}`(`sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs`)
  51 +2. DELETE FROM `tUserPermission` WHERE `iUserId = {id}`(清空旧关联)
  52 +3. 对 `permissionCategoryIds` 中的每个 id:INSERT `tUserPermission(iUserId={id}, iCategoryId=cid, sCreatedBy=同上, tCreateDate=NOW(), sBrandsId/sSubsidiaryId=配置)`
  53 +
  54 +| `tUser` 字段 | 更新策略 |
  55 +|---|---|
  56 +| `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` | DTO 透传 |
  57 +| 其他字段(`sPasswordHash` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` / `sId`) | **不更新**(entity 上对应字段保持 null,依赖 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL` 跳过 null 字段) |
  58 +
  59 +> 复用 MOD-002 / USR-001 已建立的"NOT_NULL 跳过 null 不动其他字段"策略;`bCanModifyDocs` DTO null 时 service 先展开为 `false` 再赋值。
  60 +
  61 +## 业务规则
  62 +
  63 +1. **目标存在性**:`SELECT * FROM tUser WHERE iIncrement = {id}`;行不存在 **或** `bDeleted=true` → `BizException(40400, "用户不存在或已删除")`。
  64 +2. **枚举校验**:`sUserType` / `sLanguage` 复用 USR-001 的 `Set.contains` 校验;非法 → `BizException(40001, "<字段>: 取值非法")`。
  65 +3. **iStaffId 校验**(非 null 时):`staffMapper.existsActiveById(iStaffId) == false` → `BizException(40022, "职员不存在或已删除")`。
  66 +4. **permissionCategoryIds 校验**(非空时):`permissionCategoryMapper.countActiveByIds(ids) != ids.size()` → `BizException(40023, "权限分类含无效 id")`。
  67 +5. **唯一冲突**:依赖 DB 唯一索引兜底;`userMapper.updateById` 抛 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")`。
  68 +6. **重建权限组**:先 `userPermissionMapper.deleteByUserId(id)` 全量删除,再 for-loop 插入新关联(即使 `permissionCategoryIds` 为空 / null 也执行删除,等价"清空")。
  69 +7. **事务**:`@Transactional(rollbackFor = Exception.class)` 包"5 类校验 + UPDATE user + DELETE permission + INSERT permission × N",任一步失败回滚。
  70 +
  71 +## 边界与约束
  72 +
  73 +- **必填项缺失** → `40001`
  74 +- **`sUserType` / `sLanguage` 非枚举** → `40001`
  75 +- **`sUserNo` / `sUserName` 唯一冲突** → `40020`
  76 +- **目标 id 不存在 / 已软删** → `40400`
  77 +- **iStaffId 不存在 / 已软删** → `40022`
  78 +- **permissionCategoryIds 含无效 id** → `40023`
  79 +- **JWT 伪造** → `20001`
  80 +- **JWT 缺失** → permitAll stub
  81 +- **`sPasswordHash` 不被覆盖**:DTO 不暴露字段;entity 上 sPasswordHash=null 由 NOT_NULL 跳过
  82 +
  83 +## 实现范围与边界抉择
  84 +
  85 +1. **复用 USR-001 工程**:所有 mapper / entity / dto 已就位;本 REQ 仅在 `UserService` / `UserServiceImpl` / `UserController` 上做增量 + `UserPermissionMapper` 追加 `deleteByUserId`。
  86 +2. **错误码 40022 语义复用**:docs/05 § USR-002 错误码列表只列 40001/40020/40400,未单列 40022。本 spec 选择复用 USR-001 已建立的语义,与"同字段两个接口错误码一致"原则相符(同 MOD-002 复用 MOD-001 40010 的处理)。
  87 +3. **重建权限组策略**:选"先全删再插入"而非"diff 增量更新"——典型模块权限数 < 50,diff 实现复杂度收益不匹配。
  88 +4. **iStaffId 不强校验外键 SET NULL**:DB 已 `ON DELETE SET NULL`,service 提前校验存在性给更友好错误码。
  89 +
  90 +## 依赖的 schema 表 / 字段
  91 +
  92 +写入:
  93 +- `tUser`:6 个可编辑字段(其余依赖 NOT_NULL 跳过)
  94 +- `tUserPermission`:`iIncrement` 自增 + 6 字段(先全删后批量插入)
  95 +
  96 +读取(仅校验存在性):
  97 +- `tUser`(selectById 校验目标)
  98 +- `tStaff` / `tPermissionCategory`(同 USR-001)
  99 +
  100 +依赖外键:`fk_user_staff: iStaffId → tStaff.iIncrement (ON DELETE SET NULL)` / `tUserPermission` 对 `tUser` 与 `tPermissionCategory` 的外键。
  101 +
  102 +## 依赖的接口
  103 +
  104 +无(仅本 REQ 内部使用 USR-001 已实现的 mapper + 新增 `userPermissionMapper.deleteByUserId`)。
  105 +
  106 +## 验收标准
  107 +
  108 +### 单元测试(追加到 `UserServiceImplTest`)
  109 +
  110 +- [x] `updateWithValidDto_invokesUpdateById_andRebuildsPermissions` — Mock `selectById(10)=alive`、`existsActiveById`、`countActiveByIds`;ArgumentCaptor 抓 `userMapper.updateById` + `userPermissionMapper.deleteByUserId(10)` 调用一次 + `userPermissionMapper.insert × N`;断言传入 entity 的 `iIncrement=10` / 可编辑字段被透传 / `sPasswordHash` 等不可改字段为 null
  111 +- [x] `updateWithTargetNotFound_throws40400`
  112 +- [x] `updateWithTargetAlreadyDeleted_throws40400`
  113 +- [x] `updateWithInvalidUserType_throws40001`
  114 +- [x] `updateWithInvalidLanguage_throws40001`
  115 +- [x] `updateWithStaffNotFound_throws40022`
  116 +- [x] `updateWithSomeInvalidPermissionIds_throws40023`
  117 +- [x] `updateWithDuplicateUserNo_throws40020` — Mock `userMapper.updateById` 抛 `DuplicateKeyException`
  118 +- [x] `updateWithEmptyPermissionIds_clearsExisting` — `permissionCategoryIds=null` → `deleteByUserId` 调用一次,`insert` 永不调用
  119 +- [x] `updateWithBCanModifyDocsNull_setsFalseInEntity`
  120 +
  121 +### Mapper IT(追加到 `UserMapperIT`)
  122 +
  123 +- [x] `userPermissionMapper#deleteByUserId_removesAllRowsForGivenUser` — 直插 user + 2 行 permission;调 `deleteByUserId` → tUserPermission 中该 user 的行 == 0;其他 user 不受影响
  124 +
  125 +### 集成测试(`UserControllerIT`,追加 8 用例)
  126 +
  127 +- [x] `putValidBody_with_jwt_returns200_andUpdates` — 直插 user + 2 行 permission;PUT 改 sUserName 同时改 permissionCategoryIds;DB 验:可编辑字段更新;sPasswordHash / sCreatedBy 保留原值;tUserPermission 行数 == 新 ids.size()
  128 +- [x] `putNonExistentId_returns40400`
  129 +- [x] `putAlreadyDeletedId_returns40400`
  130 +- [x] `putInvalidUserType_returns40001`
  131 +- [x] `putDuplicateUserNo_returns40020` — 先存在 user1(sUserNo=A) + user2(sUserNo=B);PUT user2 改 sUserNo=A → `code=40020`
  132 +- [x] `putStaffNotFound_returns40022`
  133 +- [x] `putPermissionCategoryNotFound_returns40023`
  134 +- [x] `putWithEmptyPermissionIds_clearsAssociations` — 直插 user + 2 行 permission;PUT 不带 permissionCategoryIds;DB 查该 user 的 tUserPermission == 0;sPasswordHash 保留原值
  135 +- [x] `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy`
  136 +- [x] `putTamperedJwt_returns20001`
  137 +
  138 +### 工程验收
  139 +
  140 +- [x] `cd backend && mvn -B test` 全绿(89 + 新增 ≥ 19 = ≥ 108 用例)
  141 +- [x] DB 中 `sPasswordHash` / `sCreatedBy` / `tCreateDate` 在 PUT 前后字面相同
  142 +- [x] tUserPermission 行集与请求 ids 完全等价(删旧 + 插新)
... ...