---
req_id: REQ-USR-001
date: 2026-04-30
spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-001.md
---

# REQ-USR-001 用户新增 Implementation Plan

> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.

**Goal:** 在 MOD 模块已建工程基础上扩展 USR 模块树，实现 `POST /api/usr/users`：新增 `tUser` 行 + 默认 BCrypt 密码哈希 + `tUserPermission` 多对多关联，含 `iStaffId` / `permissionCategoryIds` 存在性校验。

**Architecture:** 新建 `module/usr/` 平行模块树（entity/dto/mapper/service/controller）+ 在 `common/security/SecurityConfig` 加 `/api/usr/**` permitAll + 注册 `BCryptPasswordEncoder` bean。Staff / PermissionCategory 仅做存在性校验（最小化 mapper，不建 entity）。事务包"3 项校验 + INSERT user + INSERT user_permission × N"。

**Tech Stack:** 复用（Spring Boot 3.3.5 / MyBatis-Plus / Spring Security / JJWT）；本 REQ 引入 `BCryptPasswordEncoder`（spring-security-crypto 已通过 starter-security 引入，无需新依赖）。

---

## Schema 改动

无（`tUser` / `tStaff` / `tPermissionCategory` / `tUserPermission` 均在 V1 就位）。

## 文件变更清单

### 新增

- `backend/src/main/java/com/xly/erp/module/usr/entity/User.java`
- `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java`
- `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java`
- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`
- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java`
- `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java`
- `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java`
- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
- `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java`
- `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java`
- `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java`
- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`

### 修改

- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — 加 `/api/usr/**` permitAll + `@Bean BCryptPasswordEncoder`

## 任务步骤

> 全局：每 commit `<type>(usr): <subject> REQ-USR-001`；测试派发子会话；现有 67 用例全程绿。

### Task 1: SecurityConfig 扩展 + BCryptPasswordEncoder bean

**Files:**
- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java`

**API shape:**
- 在 `authorizeHttpRequests` 的现有 `requestMatchers("/api/mod/**").permitAll()` 后追加 `requestMatchers("/api/usr/**").permitAll()`
- 类内追加 `@Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); }`
- stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up`

- [ ] **Step 1: 修改 SecurityConfig**
- [ ] **Step 2: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test`
  - 期望：现有 67 用例全绿（路径扩范围不收紧）
- [ ] **Step 3: Commit**
  - `git commit -m "refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001"`

### Task 2: tUser entity + UserMapper + IT

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/User.java`
- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`
- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java`

**API shape:**
- `User` PO：`@TableName("tUser")` + 17 字段 1:1 映射（参 docs/03 § tUser），全部 `@TableField` 显式列名（与 MOD `Module.java` 风格一致），`iIncrement` 用 `@TableId(IdType.AUTO)`
- `UserMapper extends BaseMapper<User>`：暂只用 BaseMapper 的 insert/selectById（唯一冲突走 DB 索引兜底，无需自定义 exists 方法）

- [ ] **Step 1: 写失败测试 `UserMapperIT`（2 用例）**
  - `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例（含 sPasswordHash="bcrypt-stub"）插入 → selectById 比较；断言 sBrandsId="XLY"、bDeleted=false 等
  - `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo（不同 sUserName）→ 第二次抛 `DuplicateKeyException`
  - 测试隔离：`@BeforeEach @AfterEach` 用 `DELETE FROM tUserPermission WHERE iUserId IN (SELECT iIncrement FROM tUser WHERE sUserNo LIKE 'sp_test_%')` + `DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'`（避免外键孤儿，但 USR-001 的 tUserPermission 与 tUser 在本 IT 不会被插入；此句保留为防御）

- [ ] **Step 2: 实现 entity + mapper**
- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=UserMapperIT`
- [ ] **Step 4: Commit**
  - `git commit -m "feat(usr): tUser entity + mapper REQ-USR-001"`

### Task 3: StaffMapper + PermissionCategoryMapper（最小存在性查询）

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java`
- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java`
- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java`
- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java`

**API shape:**
- `StaffMapper`（**不**继承 BaseMapper，仅注解 SELECT；本 REQ 不建 Staff entity）：
  - `@Select("SELECT 1 FROM tStaff WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") Integer findActiveStaffFlag(@Param("id") Integer id)`
  - `default boolean existsActiveById(Integer id) { return findActiveStaffFlag(id) != null; }`
- `PermissionCategoryMapper`（同上）：
  - `@Select("<script>SELECT COUNT(1) FROM tPermissionCategory WHERE bDeleted = 0 AND iIncrement IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>") int countActiveByIds(@Param("ids") List<Integer> ids)`（mybatis 动态 SQL）；空 list 调用方先短路，不调 mapper

- [ ] **Step 1: 写失败测试**
  - `StaffMapperIT#existsActiveById_handlesAliveDeletedMissing`：JdbcTemplate 直插 alive staff + deleted staff；断言三种 id（alive/deleted/不存在）的返回值
  - `PermissionCategoryMapperIT#countActiveByIds_returnsCorrectCount`：JdbcTemplate 直插 cat1(alive) + cat2(alive) + cat3(deleted)；查 [cat1,cat2,cat3] → count=2；查 [cat1, 99999] → count=1；查 [99999] → count=0

- [ ] **Step 2: 实现 mapper**
- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest='StaffMapperIT,PermissionCategoryMapperIT'`
- [ ] **Step 4: Commit**
  - `git commit -m "feat(usr): staff + permission-category existence mappers REQ-USR-001"`

### Task 4: tUserPermission entity + UserPermissionMapper + IT

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java`
- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java`
- 复用 `UserMapperIT`（追加用例）或独立 `UserPermissionMapperIT`，本 plan 选择追加到 UserMapperIT

**API shape:**
- `UserPermission` PO：`@TableName("tUserPermission")` + 字段 `iIncrement(@TableId AUTO)` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `iUserId` / `iCategoryId` / `sCreatedBy`（参 docs/03 § tUserPermission）
- `UserPermissionMapper extends BaseMapper<UserPermission>`，仅用 BaseMapper.insert

- [ ] **Step 1: 写失败测试（追加到 `UserMapperIT`）**
  - `userPermissionInsert_persistsRowWithUserAndCategory` — 先插一行 user，再插一行 userPermission(iUserId=user.id, iCategoryId=10)；JdbcTemplate 验 row 存在

- [ ] **Step 2: 实现 entity + mapper**
- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=UserMapperIT`
- [ ] **Step 4: Commit**
  - `git commit -m "feat(usr): tUserPermission entity + mapper REQ-USR-001"`

### Task 5: CreateUserDTO + UserService.create 主流程（合法 + 标准列）

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java`
- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`

**API shape:**
- `CreateUserDTO` 字段（带 `@JsonProperty` + Bean Validation）：
  - `@NotBlank @Size(max=50) String sUserNo`
  - `@NotBlank @Size(max=50) String sUserName`
  - `Integer iStaffId`（可空）
  - `@NotBlank String sUserType`
  - `@NotBlank String sLanguage`
  - `Boolean bCanModifyDocs`（可空）
  - `List<Integer> permissionCategoryIds`（可空，null/空均按"无权限组"处理）
- `UserService#create(CreateUserDTO dto) : Map<String, Object>`（返回 `{iIncrement, sUserNo}`）；本 plan 用 `Map<String, Object>` 而非新 VO，与 MOD 控制器响应风格保持一致
- `UserServiceImpl` 依赖：`UserMapper` / `UserPermissionMapper` / `StaffMapper` / `PermissionCategoryMapper` / `TenantProperties` / `StubSecurityProperties` / `BCryptPasswordEncoder`
- `@Transactional(rollbackFor = Exception.class)`
- 流程主路径（仅本 task 实现合法 + 标准列）：
  1. 构造 `User entity`：DTO 透传 + 标准列（sBrandsId/sSubsidiaryId/tCreateDate/sCreatedBy 同 MOD 模块策略） + `sPasswordHash = encoder.encode("666666")` + `bDeleted=false`
  2. `userMapper.insert(entity)`
  3. 若 `permissionCategoryIds` 非空：for-loop 插 `UserPermission` 行
  4. 返回 `Map.of("iIncrement", entity.getIIncrement(), "sUserNo", entity.getSUserNo())`

- [ ] **Step 1: 写失败测试（2 用例）**
  - `createWithValidDto_persistsUser_andUserPermissions` — Mock mappers + encoder.encode 返回 "$2a$10$stub"；ArgumentCaptor 抓 `userMapper.insert` + `userPermissionMapper.insert`；断言 user.sBrandsId="XLY"、sCreatedBy="STUB_ADMIN"、sPasswordHash 以 "$2a$" 开头；N 条权限关联含正确 iUserId / iCategoryId
  - `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null`；`userPermissionMapper.insert` 永不调用

- [ ] **Step 2: 实现 DTO + service 主流程**
  - 仅覆盖本 task 两用例所需逻辑（异常分支留 Task 6）
- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=UserServiceImplTest`
- [ ] **Step 4: Commit**
  - `git commit -m "feat(usr): user create dto + service happy path REQ-USR-001"`

### Task 6: Service 异常分支补全

**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:** 不变（仅在 service 头部补 4 类校验 + 异常翻译）

**校验顺序（service 实现）：**
1. 枚举校验 `sUserType ∈ {普通用户, 超级管理员}` + `sLanguage ∈ {zh, en, zh-TW}` → 任一非法 `BizException(40001, "<字段>: 取值非法")`
2. `iStaffId != null` 且 `!staffMapper.existsActiveById(iStaffId)` → `BizException(40022, "职员不存在或已删除")`
3. `permissionCategoryIds` 非空：`int n = permissionCategoryMapper.countActiveByIds(ids); if (n != ids.size()) throw new BizException(40023, "权限分类含无效 id")`
4. `userMapper.insert(...)` 用 try/catch 捕获 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")`
5. `sCreatedBy` 优先 SecurityContextHelper.currentUserNo()，回退 stub

- [ ] **Step 1: 在 `UserServiceImplTest` 追加 6 用例**
  - `createWithInvalidUserType_throws40001`
  - `createWithInvalidLanguage_throws40001`
  - `createWithStaffNotFound_throws40022` — Mock `staffMapper.existsActiveById(...) → false`；`userMapper.insert` 永不调用
  - `createWithSomeInvalidPermissionIds_throws40023` — Mock `permissionCategoryMapper.countActiveByIds([1,2,3]) → 2`；`userMapper.insert` 永不调用
  - `createWithDuplicateUserNo_throws40020` — Mock `userMapper.insert` 抛 `DuplicateKeyException`
  - `createUsesAuthenticatedUserNoAsCreatedBy` — `SecurityContextHolder` 注 "ALICE"；ArgumentCaptor `sCreatedBy="ALICE"`

- [ ] **Step 2: 在 ServiceImpl 补 4 类校验 + 异常翻译**
- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=UserServiceImplTest`
  - 期望：2 + 6 = 8 用例全绿
- [ ] **Step 4: Commit**
  - `git commit -m "feat(usr): user create error branches REQ-USR-001"`

### Task 7: UserController POST + IT（9 用例）+ 全量回归

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`

**API shape:**
- `@RestController @RequestMapping("/api/usr")`
- `@PostMapping("/users") public Result<Map<String, Object>> create(@Valid @RequestBody CreateUserDTO dto) { return Result.ok(userService.create(dto)); }`

- [ ] **Step 1: 写失败 IT（9 用例）**
  - `postValidBody_with_jwt_returns200_andPersists` — 前置：JdbcTemplate 直插一行 staff + 两行 permission_category；POST 完整 body 带 JWT；`code=0` / `data.iIncrement>0` / `data.sUserNo == 请求值`；JdbcTemplate 验 tUser 行存在 + tUserPermission 行数 == permissionCategoryIds.size()
  - `postEmptyBody_returns40001`
  - `postInvalidUserType_returns40001` — `sUserType="火星"`
  - `postInvalidLanguage_returns40001` — `sLanguage="ja"`
  - `postDuplicateUserNo_returns40020` — 先 POST 一次成功，再 POST 同 sUserNo（不同 sUserName）→ `code=40020`
  - `postStaffNotFound_returns40022` — `iStaffId=99999990` → `code=40022`
  - `postPermissionCategoryNotFound_returns40023` — `permissionCategoryIds=[99999991]` → `code=40023`
  - `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` — DB 验 sCreatedBy="STUB_ADMIN"
  - `postTamperedJwt_returns20001` — Authorization "Bearer not.a.real.jwt"；DB 无新增行
  - 测试隔离：`@BeforeEach @AfterEach` 清理 `tUserPermission` + `tUser`（按 sUserNo LIKE 'sp_test_%'）+ 清理本 IT 创建的 tStaff / tPermissionCategory（按 sStaffNo / sCategoryCode LIKE 'sp_test_%'）；按外键依赖顺序删（tUserPermission → tUser → tStaff / tPermissionCategory）

- [ ] **Step 2: 实现 controller**
- [ ] **Step 3: 子会话跑全量回归**
  - 命令：`cd backend && mvn -B test`
  - 期望：MOD 67 + USR-001 新增 2(UserMapperIT) + 1(UserMapperIT 追加) + 1(StaffMapperIT) + 1(PermissionCategoryMapperIT) + 8(UserServiceImplTest) + 9(UserControllerIT) = 89 用例全绿
- [ ] **Step 4: Commit**
  - `git commit -m "test(usr): user create integration coverage REQ-USR-001"`

## 提交计划

| commit | 覆盖 |
|---|---|
| `refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001` | Task 1 |
| `feat(usr): tUser entity + mapper REQ-USR-001` | Task 2 |
| `feat(usr): staff + permission-category existence mappers REQ-USR-001` | Task 3 |
| `feat(usr): tUserPermission entity + mapper REQ-USR-001` | Task 4 |
| `feat(usr): user create dto + service happy path REQ-USR-001` | Task 5 |
| `feat(usr): user create error branches REQ-USR-001` | Task 6 |
| `test(usr): user create integration coverage REQ-USR-001` | Task 7 |
