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