Commit 797e9fb579616d4f3430bd1c0a099bd69d3f9142

Authored by zichun
1 parent 0e79763c

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

docs/superpowers/plans/2026-04-30-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-04-30
  4 +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-001.md
  5 +---
  6 +
  7 +# REQ-USR-001 用户新增 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 在 MOD 模块已建工程基础上扩展 USR 模块树,实现 `POST /api/usr/users`:新增 `tUser` 行 + 默认 BCrypt 密码哈希 + `tUserPermission` 多对多关联,含 `iStaffId` / `permissionCategoryIds` 存在性校验。
  12 +
  13 +**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"。
  14 +
  15 +**Tech Stack:** 复用(Spring Boot 3.3.5 / MyBatis-Plus / Spring Security / JJWT);本 REQ 引入 `BCryptPasswordEncoder`(spring-security-crypto 已通过 starter-security 引入,无需新依赖)。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(`tUser` / `tStaff` / `tPermissionCategory` / `tUserPermission` 均在 V1 就位)。
  22 +
  23 +## 文件变更清单
  24 +
  25 +### 新增
  26 +
  27 +- `backend/src/main/java/com/xly/erp/module/usr/entity/User.java`
  28 +- `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java`
  29 +- `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java`
  30 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`
  31 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java`
  32 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java`
  33 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java`
  34 +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  35 +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  36 +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  37 +- `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java`
  38 +- `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java`
  39 +- `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java`
  40 +- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  41 +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`
  42 +
  43 +### 修改
  44 +
  45 +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — 加 `/api/usr/**` permitAll + `@Bean BCryptPasswordEncoder`
  46 +
  47 +## 任务步骤
  48 +
  49 +> 全局:每 commit `<type>(usr): <subject> REQ-USR-001`;测试派发子会话;现有 67 用例全程绿。
  50 +
  51 +### Task 1: SecurityConfig 扩展 + BCryptPasswordEncoder bean
  52 +
  53 +**Files:**
  54 +- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java`
  55 +
  56 +**API shape:**
  57 +- 在 `authorizeHttpRequests` 的现有 `requestMatchers("/api/mod/**").permitAll()` 后追加 `requestMatchers("/api/usr/**").permitAll()`
  58 +- 类内追加 `@Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); }`
  59 +- stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up`
  60 +
  61 +- [ ] **Step 1: 修改 SecurityConfig**
  62 +- [ ] **Step 2: 子会话验证 PASS**
  63 + - 命令:`cd backend && mvn -B test`
  64 + - 期望:现有 67 用例全绿(路径扩范围不收紧)
  65 +- [ ] **Step 3: Commit**
  66 + - `git commit -m "refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001"`
  67 +
  68 +### Task 2: tUser entity + UserMapper + IT
  69 +
  70 +**Files:**
  71 +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/User.java`
  72 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`
  73 +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java`
  74 +
  75 +**API shape:**
  76 +- `User` PO:`@TableName("tUser")` + 17 字段 1:1 映射(参 docs/03 § tUser),全部 `@TableField` 显式列名(与 MOD `Module.java` 风格一致),`iIncrement` 用 `@TableId(IdType.AUTO)`
  77 +- `UserMapper extends BaseMapper<User>`:暂只用 BaseMapper 的 insert/selectById(唯一冲突走 DB 索引兜底,无需自定义 exists 方法)
  78 +
  79 +- [ ] **Step 1: 写失败测试 `UserMapperIT`(2 用例)**
  80 + - `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例(含 sPasswordHash="bcrypt-stub")插入 → selectById 比较;断言 sBrandsId="XLY"、bDeleted=false 等
  81 + - `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次抛 `DuplicateKeyException`
  82 + - 测试隔离:`@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 不会被插入;此句保留为防御)
  83 +
  84 +- [ ] **Step 2: 实现 entity + mapper**
  85 +- [ ] **Step 3: 子会话验证 PASS**
  86 + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT`
  87 +- [ ] **Step 4: Commit**
  88 + - `git commit -m "feat(usr): tUser entity + mapper REQ-USR-001"`
  89 +
  90 +### Task 3: StaffMapper + PermissionCategoryMapper(最小存在性查询)
  91 +
  92 +**Files:**
  93 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java`
  94 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java`
  95 +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java`
  96 +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java`
  97 +
  98 +**API shape:**
  99 +- `StaffMapper`(**不**继承 BaseMapper,仅注解 SELECT;本 REQ 不建 Staff entity):
  100 + - `@Select("SELECT 1 FROM tStaff WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") Integer findActiveStaffFlag(@Param("id") Integer id)`
  101 + - `default boolean existsActiveById(Integer id) { return findActiveStaffFlag(id) != null; }`
  102 +- `PermissionCategoryMapper`(同上):
  103 + - `@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
  104 +
  105 +- [ ] **Step 1: 写失败测试**
  106 + - `StaffMapperIT#existsActiveById_handlesAliveDeletedMissing`:JdbcTemplate 直插 alive staff + deleted staff;断言三种 id(alive/deleted/不存在)的返回值
  107 + - `PermissionCategoryMapperIT#countActiveByIds_returnsCorrectCount`:JdbcTemplate 直插 cat1(alive) + cat2(alive) + cat3(deleted);查 [cat1,cat2,cat3] → count=2;查 [cat1, 99999] → count=1;查 [99999] → count=0
  108 +
  109 +- [ ] **Step 2: 实现 mapper**
  110 +- [ ] **Step 3: 子会话验证 PASS**
  111 + - 命令:`cd backend && mvn -B test -Dtest='StaffMapperIT,PermissionCategoryMapperIT'`
  112 +- [ ] **Step 4: Commit**
  113 + - `git commit -m "feat(usr): staff + permission-category existence mappers REQ-USR-001"`
  114 +
  115 +### Task 4: tUserPermission entity + UserPermissionMapper + IT
  116 +
  117 +**Files:**
  118 +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java`
  119 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java`
  120 +- 复用 `UserMapperIT`(追加用例)或独立 `UserPermissionMapperIT`,本 plan 选择追加到 UserMapperIT
  121 +
  122 +**API shape:**
  123 +- `UserPermission` PO:`@TableName("tUserPermission")` + 字段 `iIncrement(@TableId AUTO)` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `iUserId` / `iCategoryId` / `sCreatedBy`(参 docs/03 § tUserPermission)
  124 +- `UserPermissionMapper extends BaseMapper<UserPermission>`,仅用 BaseMapper.insert
  125 +
  126 +- [ ] **Step 1: 写失败测试(追加到 `UserMapperIT`)**
  127 + - `userPermissionInsert_persistsRowWithUserAndCategory` — 先插一行 user,再插一行 userPermission(iUserId=user.id, iCategoryId=10);JdbcTemplate 验 row 存在
  128 +
  129 +- [ ] **Step 2: 实现 entity + mapper**
  130 +- [ ] **Step 3: 子会话验证 PASS**
  131 + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT`
  132 +- [ ] **Step 4: Commit**
  133 + - `git commit -m "feat(usr): tUserPermission entity + mapper REQ-USR-001"`
  134 +
  135 +### Task 5: CreateUserDTO + UserService.create 主流程(合法 + 标准列)
  136 +
  137 +**Files:**
  138 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java`
  139 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  140 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  141 +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  142 +
  143 +**API shape:**
  144 +- `CreateUserDTO` 字段(带 `@JsonProperty` + Bean Validation):
  145 + - `@NotBlank @Size(max=50) String sUserNo`
  146 + - `@NotBlank @Size(max=50) String sUserName`
  147 + - `Integer iStaffId`(可空)
  148 + - `@NotBlank String sUserType`
  149 + - `@NotBlank String sLanguage`
  150 + - `Boolean bCanModifyDocs`(可空)
  151 + - `List<Integer> permissionCategoryIds`(可空,null/空均按"无权限组"处理)
  152 +- `UserService#create(CreateUserDTO dto) : Map<String, Object>`(返回 `{iIncrement, sUserNo}`);本 plan 用 `Map<String, Object>` 而非新 VO,与 MOD 控制器响应风格保持一致
  153 +- `UserServiceImpl` 依赖:`UserMapper` / `UserPermissionMapper` / `StaffMapper` / `PermissionCategoryMapper` / `TenantProperties` / `StubSecurityProperties` / `BCryptPasswordEncoder`
  154 +- `@Transactional(rollbackFor = Exception.class)`
  155 +- 流程主路径(仅本 task 实现合法 + 标准列):
  156 + 1. 构造 `User entity`:DTO 透传 + 标准列(sBrandsId/sSubsidiaryId/tCreateDate/sCreatedBy 同 MOD 模块策略) + `sPasswordHash = encoder.encode("666666")` + `bDeleted=false`
  157 + 2. `userMapper.insert(entity)`
  158 + 3. 若 `permissionCategoryIds` 非空:for-loop 插 `UserPermission` 行
  159 + 4. 返回 `Map.of("iIncrement", entity.getIIncrement(), "sUserNo", entity.getSUserNo())`
  160 +
  161 +- [ ] **Step 1: 写失败测试(2 用例)**
  162 + - `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
  163 + - `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null`;`userPermissionMapper.insert` 永不调用
  164 +
  165 +- [ ] **Step 2: 实现 DTO + service 主流程**
  166 + - 仅覆盖本 task 两用例所需逻辑(异常分支留 Task 6)
  167 +- [ ] **Step 3: 子会话验证 PASS**
  168 + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest`
  169 +- [ ] **Step 4: Commit**
  170 + - `git commit -m "feat(usr): user create dto + service happy path REQ-USR-001"`
  171 +
  172 +### Task 6: Service 异常分支补全
  173 +
  174 +**Files:**
  175 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  176 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  177 +
  178 +**API shape:** 不变(仅在 service 头部补 4 类校验 + 异常翻译)
  179 +
  180 +**校验顺序(service 实现):**
  181 +1. 枚举校验 `sUserType ∈ {普通用户, 超级管理员}` + `sLanguage ∈ {zh, en, zh-TW}` → 任一非法 `BizException(40001, "<字段>: 取值非法")`
  182 +2. `iStaffId != null` 且 `!staffMapper.existsActiveById(iStaffId)` → `BizException(40022, "职员不存在或已删除")`
  183 +3. `permissionCategoryIds` 非空:`int n = permissionCategoryMapper.countActiveByIds(ids); if (n != ids.size()) throw new BizException(40023, "权限分类含无效 id")`
  184 +4. `userMapper.insert(...)` 用 try/catch 捕获 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")`
  185 +5. `sCreatedBy` 优先 SecurityContextHelper.currentUserNo(),回退 stub
  186 +
  187 +- [ ] **Step 1: 在 `UserServiceImplTest` 追加 6 用例**
  188 + - `createWithInvalidUserType_throws40001`
  189 + - `createWithInvalidLanguage_throws40001`
  190 + - `createWithStaffNotFound_throws40022` — Mock `staffMapper.existsActiveById(...) → false`;`userMapper.insert` 永不调用
  191 + - `createWithSomeInvalidPermissionIds_throws40023` — Mock `permissionCategoryMapper.countActiveByIds([1,2,3]) → 2`;`userMapper.insert` 永不调用
  192 + - `createWithDuplicateUserNo_throws40020` — Mock `userMapper.insert` 抛 `DuplicateKeyException`
  193 + - `createUsesAuthenticatedUserNoAsCreatedBy` — `SecurityContextHolder` 注 "ALICE";ArgumentCaptor `sCreatedBy="ALICE"`
  194 +
  195 +- [ ] **Step 2: 在 ServiceImpl 补 4 类校验 + 异常翻译**
  196 +- [ ] **Step 3: 子会话验证 PASS**
  197 + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest`
  198 + - 期望:2 + 6 = 8 用例全绿
  199 +- [ ] **Step 4: Commit**
  200 + - `git commit -m "feat(usr): user create error branches REQ-USR-001"`
  201 +
  202 +### Task 7: UserController POST + IT(9 用例)+ 全量回归
  203 +
  204 +**Files:**
  205 +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  206 +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`
  207 +
  208 +**API shape:**
  209 +- `@RestController @RequestMapping("/api/usr")`
  210 +- `@PostMapping("/users") public Result<Map<String, Object>> create(@Valid @RequestBody CreateUserDTO dto) { return Result.ok(userService.create(dto)); }`
  211 +
  212 +- [ ] **Step 1: 写失败 IT(9 用例)**
  213 + - `postValidBody_with_jwt_returns200_andPersists` — 前置:JdbcTemplate 直插一行 staff + 两行 permission_category;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo == 请求值`;JdbcTemplate 验 tUser 行存在 + tUserPermission 行数 == permissionCategoryIds.size()
  214 + - `postEmptyBody_returns40001`
  215 + - `postInvalidUserType_returns40001` — `sUserType="火星"`
  216 + - `postInvalidLanguage_returns40001` — `sLanguage="ja"`
  217 + - `postDuplicateUserNo_returns40020` — 先 POST 一次成功,再 POST 同 sUserNo(不同 sUserName)→ `code=40020`
  218 + - `postStaffNotFound_returns40022` — `iStaffId=99999990` → `code=40022`
  219 + - `postPermissionCategoryNotFound_returns40023` — `permissionCategoryIds=[99999991]` → `code=40023`
  220 + - `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` — DB 验 sCreatedBy="STUB_ADMIN"
  221 + - `postTamperedJwt_returns20001` — Authorization "Bearer not.a.real.jwt";DB 无新增行
  222 + - 测试隔离:`@BeforeEach @AfterEach` 清理 `tUserPermission` + `tUser`(按 sUserNo LIKE 'sp_test_%')+ 清理本 IT 创建的 tStaff / tPermissionCategory(按 sStaffNo / sCategoryCode LIKE 'sp_test_%');按外键依赖顺序删(tUserPermission → tUser → tStaff / tPermissionCategory)
  223 +
  224 +- [ ] **Step 2: 实现 controller**
  225 +- [ ] **Step 3: 子会话跑全量回归**
  226 + - 命令:`cd backend && mvn -B test`
  227 + - 期望:MOD 67 + USR-001 新增 2(UserMapperIT) + 1(UserMapperIT 追加) + 1(StaffMapperIT) + 1(PermissionCategoryMapperIT) + 8(UserServiceImplTest) + 9(UserControllerIT) = 89 用例全绿
  228 +- [ ] **Step 4: Commit**
  229 + - `git commit -m "test(usr): user create integration coverage REQ-USR-001"`
  230 +
  231 +## 提交计划
  232 +
  233 +| commit | 覆盖 |
  234 +|---|---|
  235 +| `refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001` | Task 1 |
  236 +| `feat(usr): tUser entity + mapper REQ-USR-001` | Task 2 |
  237 +| `feat(usr): staff + permission-category existence mappers REQ-USR-001` | Task 3 |
  238 +| `feat(usr): tUserPermission entity + mapper REQ-USR-001` | Task 4 |
  239 +| `feat(usr): user create dto + service happy path REQ-USR-001` | Task 5 |
  240 +| `feat(usr): user create error branches REQ-USR-001` | Task 6 |
  241 +| `test(usr): user create integration coverage REQ-USR-001` | Task 7 |
... ...
docs/superpowers/specs/2026-04-30-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-04-30
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-001 — 用户新增
  8 +
  9 +## 目标
  10 +
  11 +录入新用户基本信息 + 初始化默认密码(`666666` 的 BCrypt 哈希)+ 同步建立用户与权限分类的多对多关联,返回新用户 `iIncrement` + `sUserNo`。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +### HTTP 接口(docs/05 § REQ-USR-001)
  16 +
  17 +- Method / Path: `POST /api/usr/users`
  18 +- Auth: 必需(沿用 MOD 模块 stub:本 REQ 在 SecurityConfig 加 `/api/usr/**` permitAll,USR-004 闭环时统一改为 `hasAuthority('SUPER_ADMIN')`)
  19 +- Permission: 仅超级管理员(stub 期不强制)
  20 +
  21 +### 请求 DTO `CreateUserDTO`
  22 +
  23 +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 |
  24 +|---|---|---|---|---|
  25 +| `sUserNo` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_no`);冲突 → `40020` |
  26 +| `sUserName` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_name`);冲突 → `40020` |
  27 +| `iStaffId` | `Integer` | 否 | — | 非 null 时必须命中 `tStaff` 中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40022` |
  28 +| `sUserType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[普通用户, 超级管理员]` 内;非法 → `40001`(沿用 docs/05 错误码列表) |
  29 +| `sLanguage` | `String` | 是 | `@NotBlank` | 必须在枚举 `[zh, en, zh-TW]`(代码值,前端做 i18n 标签映射)内;非法 → `40001` |
  30 +| `bCanModifyDocs` | `Boolean` | 否 | — | 缺省 `false` |
  31 +| `permissionCategoryIds` | `List<Integer>` | 否 | — | 非空时所有 id 必须在 `tPermissionCategory` 中存在 + `bDeleted=0`;任一不合法 → `40023`。空 list / null → 不建关联 |
  32 +
  33 +### 鉴权与上下文
  34 +
  35 +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 token → `code=20001`;缺失 token → permitAll 透传。`sCreatedBy` 取 `SecurityContextHelper.currentUserNo()`,匿名状态回退 `stubProps.stubUserNo`(与 MOD 模块同策略)。
  36 +
  37 +## 输出 / 结果
  38 +
  39 +### 成功响应
  40 +
  41 +```json
  42 +{ "code": 0, "msg": "ok", "data": { "iIncrement": 456, "sUserNo": "u001" } }
  43 +```
  44 +
  45 +### 持久化效果
  46 +
  47 +事务内两步:
  48 +
  49 +1. INSERT `tUser`:DTO 字段 + 标准列 + `sPasswordHash = bcrypt("666666")` + `tLastLoginDate=NULL` + `bDeleted=0`
  50 +2. 对 `permissionCategoryIds` 中的每个 id:INSERT `tUserPermission(iUserId=新用户.iIncrement, iCategoryId=id, sCreatedBy=同上, tCreateDate=NOW(), sBrandsId/sSubsidiaryId=配置默认)`
  51 +
  52 +| `tUser` 字段 | 来源 |
  53 +|---|---|
  54 +| `iIncrement` | DB 自增 |
  55 +| `sId` | NULL |
  56 +| `sBrandsId` / `sSubsidiaryId` | `TenantProperties`(XLY/XLY) |
  57 +| `tCreateDate` | `LocalDateTime.now()` |
  58 +| `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` | DTO 透传 |
  59 +| `sPasswordHash` | `BCryptPasswordEncoder.encode("666666")`(每次 hash salt 不同) |
  60 +| `tLastLoginDate` | NULL(USR-004 登录时更新) |
  61 +| `sCreatedBy` | JWT principal 或 stub |
  62 +| `bDeleted` | `0` |
  63 +| `tDeletedDate` / `sDeletedBy` | NULL |
  64 +
  65 +## 业务规则
  66 +
  67 +1. **唯一性策略**:DB `uk_user_no` + `uk_user_name` 包含已软删行;本期不支持"软删后用户号复用"——若 sUserNo 历史上有过删除记录,再次创建会被 DB 唯一索引拒绝。docs/03 § tUser 业务注记说"应用层保证(仅约束未删除部分)"是设计意向,本 REQ 实现保持与 V1 schema 一致:依赖 DB 唯一索引兜底,service 层捕获 `DuplicateKeyException` → `BizException(40020,"用户号或用户名已存在")`,不再做"先查后插"二次校验(避免竞态)。
  68 +2. **iStaffId 校验**:非 null 时调 `staffMapper.existsActiveById(iStaffId)`;false → `BizException(40022,"职员不存在或已删除")`。
  69 +3. **permissionCategoryIds 校验**:非空时一次性查 `permissionCategoryMapper.countActiveByIds(ids)`,若返回数 != ids.size → `BizException(40023,"权限分类含无效 id")`。**避免 N+1**(一条 IN 查询)。
  70 +4. **sUserType / sLanguage 枚举**:service 入口处用 `Set.contains` 校验;非法 → `BizException(40001, "<字段名>: 取值非法")`。
  71 +5. **密码哈希**:使用 Spring Security 的 `BCryptPasswordEncoder`(已通过 starter-security 引入),强度默认 10。`@Bean BCryptPasswordEncoder` 在 `SecurityConfig` 注册(仅本 REQ 引入,避免循环依赖,参 § 实现范围抉择)。
  72 +6. **事务**:service 上 `@Transactional(rollbackFor = Exception.class)`,包"校验 + INSERT user + INSERT user_permission * N"。任一步骤失败回滚,不留残行。
  73 +7. **批量插入策略**:本 REQ 用 `for (Integer id : ids) userPermissionMapper.insert(rec)` 简单循环。permissionCategoryIds 典型 < 50,N+1 影响可接受。后续若性能瓶颈再改 batch INSERT。
  74 +
  75 +## 边界与约束
  76 +
  77 +- **必填项缺失** → `40001`
  78 +- **`sUserType` / `sLanguage` 非枚举** → `40001`
  79 +- **`sUserNo` / `sUserName` 唯一冲突** → `40020`
  80 +- **`iStaffId` 不存在 / 已软删** → `40022`
  81 +- **`permissionCategoryIds` 含无效 id / 已软删** → `40023`
  82 +- **JWT 伪造** → `20001`
  83 +- **JWT 缺失** → permitAll stub(USR-004 后改 401)
  84 +- **不返回 `sPasswordHash`**:响应 data 仅含 `iIncrement` + `sUserNo`,避免哈希泄漏
  85 +
  86 +## 实现范围与边界抉择
  87 +
  88 +1. **复用 MOD 模块工程**:无新增 pom 依赖;`backend/src/main/java/com/xly/erp/module/usr/` 全新模块树,与 `module/mod/` 平行。
  89 +2. **SecurityConfig 路径扩展**:在现有 `/api/mod/**` permitAll 同位加 `/api/usr/**` permitAll,stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up`(USR-004 时整段一次性收紧)。
  90 +3. **`BCryptPasswordEncoder` 注册位置**:本 REQ 在 `SecurityConfig` 加 `@Bean BCryptPasswordEncoder`,service 通过构造器注入。USR-004 登录接口同样依赖此 bean,无重复定义。
  91 +4. **Staff / PermissionCategory 仅做存在性校验**:本 REQ 不建 `Staff` / `PermissionCategory` 完整 entity,仅建 `StaffMapper` / `PermissionCategoryMapper` 两个最小化接口(注解 SELECT 1 / SELECT COUNT)。后续 USR-002/003 真正用到完整字段时再补 entity。
  92 +5. **唯一冲突处理走 DB 索引兜底**:与 MOD-001 `sProcedureName` 风格一致;不做"先查后插"避免竞态。
  93 +6. **批量插入 `tUserPermission` 暂用循环**:本 REQ 数据量小,YAGNI;性能问题后续 REQ 出现时再优化。
  94 +7. **测试用 stub JWT 注入**:复用 `TestJwtHelper`,无需新建。
  95 +
  96 +## 依赖的 schema 表 / 字段
  97 +
  98 +写入:
  99 +- `tUser`:14 个字段(除 `tDeletedDate` / `sDeletedBy`)
  100 +- `tUserPermission`:`iUserId` / `iCategoryId` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId`
  101 +
  102 +读取(仅校验存在性):
  103 +- `tStaff`:`iIncrement` + `bDeleted`
  104 +- `tPermissionCategory`:`iIncrement` + `bDeleted`
  105 +
  106 +依赖索引:`tUser.uk_user_no` / `uk_user_name` 兜底唯一冲突;`tStaff.iIncrement` PK;`tPermissionCategory.iIncrement` PK。
  107 +
  108 +外键:`fk_user_staff: iStaffId → tStaff.iIncrement (ON DELETE SET NULL)` 在 INSERT 时由 DB 兜底(service 提前校验给更友好错误码)。
  109 +
  110 +## 依赖的接口
  111 +
  112 +无(USR-001 是用户域 CRUD 起点)。
  113 +
  114 +## 验收标准
  115 +
  116 +### 单元测试(`UserServiceImplTest`,Mockito)
  117 +
  118 +- [x] `createWithValidDto_persistsUser_andUserPermissions` — Mock mappers,ArgumentCaptor 抓 `userMapper.insert` + `userPermissionMapper.insert × N`;断言:`sBrandsId="XLY"` / `sCreatedBy="STUB_ADMIN"` / `tCreateDate != null` / `sPasswordHash` 是 BCrypt 格式(以 `$2a$` 或 `$2b$` 开头)/ N 条权限关联含正确 `iUserId` / `iCategoryId`
  119 +- [x] `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null` / 空 list → `userPermissionMapper.insert` 永不调用
  120 +- [x] `createWithInvalidUserType_throws40001`
  121 +- [x] `createWithInvalidLanguage_throws40001`
  122 +- [x] `createWithStaffNotFound_throws40022` — `staffMapper.existsActiveById(...)=false`
  123 +- [x] `createWithSomeInvalidPermissionIds_throws40023` — `permissionCategoryMapper.countActiveByIds([1,2,3])=2`,期望抛 40023;`userMapper.insert` 永不调用
  124 +- [x] `createWithDuplicateUserNo_throws40020` — `userMapper.insert` 抛 `DuplicateKeyException`
  125 +- [x] `createUsesAuthenticatedUserNoAsCreatedBy` — SecurityContextHolder 注 "ALICE",断言 `sCreatedBy="ALICE"`
  126 +
  127 +### Mapper IT(`UserMapperIT`,真实 DB)
  128 +
  129 +- [x] `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例插入,`selectById` 比较;`sPasswordHash` 非空且 BCrypt 格式
  130 +- [x] `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次 `DuplicateKeyException`
  131 +
  132 +### Mapper IT(`StaffMapperIT` + `PermissionCategoryMapperIT`,最小化)
  133 +
  134 +- [x] `staffMapper#existsActiveById_handlesAliveDeletedMissing`
  135 +- [x] `permissionCategoryMapper#countActiveByIds_returnsCorrectCount`
  136 +
  137 +### 集成测试(`UserControllerIT`)
  138 +
  139 +- [x] `postValidBody_with_jwt_returns200_andPersists` — 直插一条职员 + 两条权限分类作为前置数据;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo` 等于请求;JdbcTemplate 验证 `tUser` + `tUserPermission` 行
  140 +- [x] `postEmptyBody_returns40001`
  141 +- [x] `postInvalidUserType_returns40001`
  142 +- [x] `postInvalidLanguage_returns40001`
  143 +- [x] `postDuplicateUserNo_returns40020`
  144 +- [x] `postStaffNotFound_returns40022`
  145 +- [x] `postPermissionCategoryNotFound_returns40023`
  146 +- [x] `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN`
  147 +- [x] `postTamperedJwt_returns20001`
  148 +
  149 +### 工程验收
  150 +
  151 +- [x] `cd backend && mvn -B test` 全绿(67 + USR-001 新增 8(svc) + 4(mapperIT) + 9(controllerIT) = 88 用例)
  152 +- [x] `BCryptPasswordEncoder` bean 在 `SecurityConfig` 注册,service 通过构造器注入
  153 +- [x] SecurityConfig 路径白名单含 `/api/usr/**`
  154 +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持
  155 +- [x] `tUser.sPasswordHash` 在响应中**不**回显
... ...