Commit 06d4c5a41b4ff7627ac8b1c8bd510bc183cf0297
1 parent
e5923022
docs(usr): spec + plan REQ-USR-002
Showing
2 changed files
with
270 additions
and
0 deletions
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 完全等价(删旧 + 插新) |