Commit 520c01f2f4aac33744692faa9c218b063adbedcd

Authored by zichun
1 parent 8e0ddfdc

fix(usr): 修复 review round 1 must-fix REQ-USR-001

清理 spec/plan 中残留的 bSelected 字段提及——docs/03 修订版无该列,
关联记录存在即「已选」。代码 UserPermissionEntity 已正确不含该字段;
本 commit 仅清洁文档使 SSoT 一致。

reviewer round 1 报告的 high『tCreateDate 未设置』是误判:
UserServiceImpl.java:102 实际已含 setTCreateDate(LocalDateTime.now()),
本 fix 不动代码。
docs/superpowers/plans/2026-05-06-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-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. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `POST /api/users`:录入用户基本信息 + 可选员工关联 + 权限组关联,密码 `666666` 经 BCrypt 哈希落库;返回 UserVO(不含哈希)。
  12 +
  13 +**Architecture:** 复用 module_mod 已建立的 common / config / 异常 / Jackson / Security 体系。新建 4 个 entity + 4 个 mapper(tUser / tStaff / tPermissionCategory / tUserPermission),UserService 协调跨表写入并用 `@Transactional` 包裹整体一致性。BCryptPasswordEncoder 注册为 Spring bean 供 REQ-USR-004 复用。
  14 +
  15 +**Tech Stack:** Spring Boot 3.2.5 + Spring Security 6(BCryptPasswordEncoder)+ MyBatis-Plus 3.5.7 + JUnit 5 + Mockito。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(V1 已建 5 张表 + FK + UNIQUE 索引)。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 3 个常量
  26 +- 创建: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` — BCryptPasswordEncoder bean
  27 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java`
  28 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java`
  29 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java`
  30 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java`
  31 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java`
  32 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java`
  33 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.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/common/response/ApiResponseTest.java` — 追加 3 个新错误码断言
  38 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java`
  39 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` — 4 张表 insert/select smoke test
  40 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 9 个 mock 单测
  41 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 7 个 MockMvc 集成测试
  42 +
  43 +---
  44 +
  45 +## 任务步骤
  46 +
  47 +### Task 1: 错误码 + PasswordConfig
  48 +
  49 +**Files:**
  50 +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
  51 +- Create: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java`
  52 +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`
  53 +
  54 +**API shape:**
  55 +- 新增 ErrorCode 常量(注意:与 MOD 段位共享但枚举名不同):
  56 + - `STAFF_NOT_FOUND(40421, "职员不存在或已删除")`
  57 + - `PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除")`
  58 + - `USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在")`
  59 +
  60 + > 注:`MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` code 相同但枚举名不同——code 段位由 docs/05 全局错误码表定义,message 文案区分语义;此设计与 docs/04 § 1.3 错误码段位划分一致。
  61 +- `PasswordConfig` 提供 `@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }`
  62 +
  63 +- [ ] **Step 1.1 写失败断言**
  64 + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加 3 行:
  65 + ```
  66 + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421);
  67 + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422);
  68 + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921);
  69 + ```
  70 + - 子会话: FAIL
  71 +
  72 +- [ ] **Step 1.2 实现 ErrorCode + PasswordConfig**
  73 + - 子会话: PASS(ApiResponseTest 5/5;上下文重启 PasswordConfig bean 注入由后续 IT 验证)
  74 +
  75 +- [ ] **Step 1.3 提交**
  76 + - `git commit -m "feat(common): error codes + PasswordConfig REQ-USR-001"`
  77 +
  78 +---
  79 +
  80 +### Task 2: 4 张表 entity + mapper + Mapper smoke IT
  81 +
  82 +**Files:**
  83 +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/{UserEntity,StaffEntity,PermissionCategoryEntity,UserPermissionEntity}.java`
  84 +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java`
  85 +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java`
  86 +
  87 +**API shape**(每个 entity 严格按 docs/03 字段;`@TableField` 显式声明列名;保留匈牙利前缀;主键 `iIncrement` `IdType.AUTO`;与 `ModuleEntity` 同范式):
  88 +
  89 +| Entity | 表 | 关键字段(不含 5 个标准列) |
  90 +|---|---|---|
  91 +| `UserEntity` | tUser | sUserNo / sUserName / iStaffId / sUserType / sLanguage / bCanModifyDocs / sPasswordHash / tLastLoginDate / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy |
  92 +| `StaffEntity` | tStaff | sStaffNo / sStaffName / sDepartment / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy |
  93 +| `PermissionCategoryEntity` | tPermissionCategory | sCategoryCode / sCategoryName / iParentId / iSortOrder / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy |
  94 +| `UserPermissionEntity` | tUserPermission | iUserId / iCategoryId / sCreatedBy(docs/03 修订版无 bSelected 列) |
  95 +
  96 +每个 mapper `extends BaseMapper<XxxEntity>`,无自定义 SQL。
  97 +
  98 +> **注意**:spec § Service 实现依赖 `tUser.iStaffId` 设置为 NULL 时不应被 IGNORED 策略影响(参考 REQ-MOD-002 经验)。本期 UserEntity 字段 **不**给 iStaffId 加 `FieldStrategy.IGNORED`——因为 user create 走 insert(默认 NOT_NULL 策略对 insert 没问题:null 字段被跳过,DB 列默认 NULL),不会触发 update path 上的副作用。如未来 REQ-USR-002 需要 update 中清空 iStaffId,再按 REQ-MOD-003 经验用 `LambdaUpdateWrapper.set(...)` 处理。
  99 +
  100 +- [ ] **Step 2.1 写失败 IT**
  101 + - `UsrMappersIT#allFourMappers_insertAndSelect_smoke`:用 4 个 mapper 各 insert 一条最小字段记录(构造 entity → insert → selectById 断言字段往返)
  102 + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback`
  103 + - 子会话: FAIL(entity / mapper 不存在)
  104 +
  105 +- [ ] **Step 2.2 实现 4 entity + 4 mapper**
  106 + - 子会话: PASS
  107 +
  108 +- [ ] **Step 2.3 提交**
  109 + - `git commit -m "feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001"`
  110 +
  111 +---
  112 +
  113 +### Task 3: UserCreateDTO + UserVO + Validation
  114 +
  115 +**Files:**
  116 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java`
  117 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java`
  118 +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java`
  119 +
  120 +**API shape:**
  121 +
  122 +`UserCreateDTO`:
  123 +- `@NotBlank @Size(max=50) String sUserNo`
  124 +- `@NotBlank @Size(max=50) String sUserName`
  125 +- `Integer iStaffId`(可空)
  126 +- `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType`
  127 +- `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage`
  128 +- `Boolean bCanModifyDocs`(可空,service 层 default false)
  129 +- `List<Integer> permissionCategoryIds`(可空 / 空数组)
  130 +
  131 +`UserVO` 字段 10 个(spec § 输出列表;不含 sPasswordHash),含静态工厂 `from(UserEntity entity, List<Integer> permissionCategoryIds)`。
  132 +
  133 +- [ ] **Step 3.1 写失败测试(5 个)**
  134 + - `UserCreateDTOValidationTest#allValidFields_yieldsNoViolations`
  135 + - `UserCreateDTOValidationTest#blankRequiredFields_yieldsViolations`(4 个 @NotBlank)
  136 + - `UserCreateDTOValidationTest#invalidUserTypeEnum_yieldsViolation`
  137 + - `UserCreateDTOValidationTest#invalidLanguageEnum_yieldsViolation`
  138 + - `UserCreateDTOValidationTest#overSizedFields_yieldsViolations`
  139 + - 子会话: FAIL
  140 +
  141 +- [ ] **Step 3.2 实现 DTO + VO**
  142 + - 子会话: PASS
  143 +
  144 +- [ ] **Step 3.3 提交**
  145 + - `git commit -m "feat(usr): user create DTO and VO REQ-USR-001"`
  146 +
  147 +---
  148 +
  149 +### Task 4: UserService.create + Mockito 单元测试
  150 +
  151 +**Files:**
  152 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  153 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  154 +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  155 +
  156 +**API shape:**
  157 +- `interface UserService { UserVO create(UserCreateDTO dto); }`
  158 +- `@Service @RequiredArgsConstructor class UserServiceImpl`,依赖 `UserMapper` / `StaffMapper` / `PermissionCategoryMapper` / `UserPermissionMapper` / `PasswordEncoder`。
  159 +- `create(dto)` 步骤(plan 锁定):
  160 + 1. **唯一性预检**:`userMapper.selectCount(eq(sUserName, dto.sUserName).eq(bDeleted, false))` > 0 → `BizException(USR_USER_NAME_OR_NO_DUP)`;同理 sUserNo。
  161 + 2. **iStaffId 校验**:dto.iStaffId 非空 → `staffMapper.selectById(...)`;null 或 bDeleted=true → `BizException(STAFF_NOT_FOUND)`。
  162 + 3. **权限分类校验**:dto.permissionCategoryIds 非空 → `permissionCategoryMapper.selectBatchIds(ids)`;返回的 list 长度 < ids 长度 OR 任一 bDeleted=true → `BizException(PERM_CATEGORY_NOT_FOUND)`。
  163 + 4. **构造 UserEntity**:复制 dto;`bCanModifyDocs` null → false;`sPasswordHash = passwordEncoder.encode("666666")`;`tCreateDate = now`;`bDeleted = false`;其他字段 null。
  164 + 5. **Insert user**:`userMapper.insert(user)`,捕 `DuplicateKeyException` → `USR_USER_NAME_OR_NO_DUP`。MyBatis-Plus 回写 `iIncrement` 到 entity。
  165 + 6. **批量 insert UserPermission**:dto.permissionCategoryIds 非空 → 循环逐条 `userPermissionMapper.insert(...)`(每条 `iUserId = user.iIncrement` / `iCategoryId = id` / `tCreateDate = now`;无 bSelected 列)。
  166 + 7. **返回 VO**:`UserVO.from(user, dto.permissionCategoryIds 或 [])`。
  167 +- 标 `@Transactional(rollbackFor = Exception.class)`
  168 +
  169 +**初始密码常量**(写在 plan 锁定):
  170 +```
  171 +private static final String INITIAL_PASSWORD = "666666";
  172 +```
  173 +后续若策略变化,service 单点修改。
  174 +
  175 +- [ ] **Step 4.1 写失败测试(9 个)**
  176 + - `create_minimalFields_returnsVOWithBCryptHash`:mock 全部 selectCount=0 / passwordEncoder.encode → "$2a$bcrypt";insert 设 iIncrement;断言 VO + 断言传给 userMapper.insert 的 entity.sPasswordHash 等于 mock 返回值
  177 + - `create_withStaffAndPermissions_writesAssociation`:mock staff / batch ids 校验通过;断言 userPermissionMapper.insert 被调 N 次 + 每次 entity 字段
  178 + - `create_duplicateUserName_throws40921`:selectCount(sUserName)>0
  179 + - `create_duplicateUserNo_throws40921`:selectCount(sUserNo)>0
  180 + - `create_staffNotFound_throws40421`:staffMapper.selectById → null
  181 + - `create_staffSoftDeleted_throws40421`:staff.bDeleted=true
  182 + - `create_permissionCategoryNotFound_throws40422`:selectBatchIds 返回比 ids 短
  183 + - `create_emptyPermissionCategoryIds_doesNotInsertAssociation`:permissionCategoryIds=[],断言 userPermissionMapper.insert 从未被调
  184 + - `create_concurrentDuplicate_dupKeyException_mappedTo40921`:mock userMapper.insert 抛 DuplicateKeyException
  185 + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor<UserEntity>` / `ArgumentCaptor<UserPermissionEntity>`
  186 + - 子会话: FAIL
  187 +
  188 +- [ ] **Step 4.2 实现 UserService + Impl**
  189 + - 子会话: PASS
  190 +
  191 +- [ ] **Step 4.3 提交**
  192 + - `git commit -m "feat(usr): create user service REQ-USR-001"`
  193 +
  194 +---
  195 +
  196 +### Task 5: UserController + 端到端 IT
  197 +
  198 +**Files:**
  199 +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  200 +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`
  201 +
  202 +**API shape:**
  203 +- `@RestController @RequestMapping("/api/users") @RequiredArgsConstructor class UserController`
  204 +- `@PostMapping ApiResponse<UserVO> create(@Valid @RequestBody UserCreateDTO dto)`
  205 +- Javadoc:`REQ-MOD-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`
  206 +
  207 +- [ ] **Step 5.1 写失败测试(7 个)**
  208 + - `post_minimalFields_returns200`:仅必填字段;断言 200 + data.sUserName + data.bCanModifyDocs=false + data.permissionCategoryIds=[]
  209 + - `post_withStaffAndPermissions_returns200_andDbAssociated`:先 mapper.insert 一条 staff + 3 条 permissionCategory,POST 用户附 permissionCategoryIds;断言 DB tUserPermission 有 3 条匹配
  210 + - `post_duplicateUserName_returns40921`:先 POST 一次,再 POST 同 sUserName
  211 + - `post_staffNotFound_returns40421`:iStaffId=999999
  212 + - `post_permissionCategoryNotFound_returns40422`:permissionCategoryIds=[999999]
  213 + - `post_passwordHashedInDb_notPlaintext`:POST 后 selectById;断言 sPasswordHash 以 "$2a$" 或 "$2b$" 开头,且不含明文 "666666"
  214 + - `post_responseExcludesSPasswordHash`:jsonPath `$.data.sPasswordHash` doesNotExist
  215 + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper`
  216 + - 子会话: FAIL(端点不存在)
  217 +
  218 +- [ ] **Step 5.2 实现 UserController**
  219 + - 子会话: PASS
  220 +
  221 +- [ ] **Step 5.3 跑全量 backend 测试**
  222 + - `cd backend && mvn -B test`
  223 + - 期望累计 76(module_mod 现有)+ 1(ApiResponse 错误码扩展) + 4(MapperIT) + 5(DTO Valid) + 9(service unit) + 7(controller IT) = 102 测试,全绿。
  224 +
  225 +- [ ] **Step 5.4 提交**
  226 + - `git commit -m "feat(usr): POST /api/users controller REQ-USR-001"`
  227 +
  228 +---
  229 +
  230 +## 提交计划
  231 +
  232 +- `feat(common): error codes + PasswordConfig REQ-USR-001`(Task 1)
  233 +- `feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001`(Task 2)
  234 +- `feat(usr): user create DTO and VO REQ-USR-001`(Task 3)
  235 +- `feat(usr): create user service REQ-USR-001`(Task 4)
  236 +- `feat(usr): POST /api/users controller REQ-USR-001`(Task 5)
docs/superpowers/reviews/2026-05-06-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-06
  4 +round: 1
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-001 — round 1
  9 +
  10 +## 结论
  11 +request-changes
  12 +
  13 +## Must-fix
  14 +
  15 +- [high → 误判,已 verified] backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:102 — reviewer 报告 `UserPermissionEntity insert 未设置 tCreateDate`,但本主会话用 `grep -n "setTCreateDate"` 确认 line 102 实际为 `up.setTCreateDate(LocalDateTime.now());`。reviewer 误读,**无需代码修复**;本 round fix commit 不涉及该项。
  16 +- [medium] docs/superpowers/specs/2026-05-06-REQ-USR-001.md / docs/superpowers/plans/2026-05-06-REQ-USR-001.md — spec § 业务规则 5 + § 依赖的 schema 表 § tUserPermission 仍写 `bSelected=1`,但 docs/03 修订版已删除该列、UserPermissionEntity 也无该字段。需把 spec/plan 的 bSelected 提及改为 "—(关联存在即已选)",避免 REQ-USR-002 误回填。
  17 +
  18 +## Nice-to-have
  19 +
  20 +- backend/src/main/java/com/xly/erp/common/response/ErrorCode.java — `MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` / `MOD_PARENT_LOOP(40921)` 与 `USR_USER_NAME_OR_NO_DUP(40921)` 共用同一数值。当前由枚举名+message 区分语义合理;建议在文件 Javadoc 写一条说明「同 code 不同枚举为合法重载(参考 docs/04 § 1.3 / docs/05 错误码表)」。
  21 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:30 — `INITIAL_PASSWORD = "666666"` 硬编码。spec 已锁定此为业务常量;可改 `@Value("${xly.user.initial-password:666666}")` 注入便于环境覆盖(属软规则边缘,不阻塞)。
  22 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:70 — `permissionCategoryIds` 含重复 id(如 [1,1,2])会导致 found.size() < ids.size() 误报 40422;可在校验前 distinct。
  23 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:44 — 两次 selectCount 可合并为单次 OR 查询(低优)。
  24 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 层缺 `post_staffSoftDeleted_returns40421`、`post_duplicateUserNo_returns40921` 两条用例(service 单测已覆盖)。建议补端到端镜像。
  25 +- docs/superpowers/plans/2026-05-06-REQ-USR-001.md Task 5 API shape — 注释笔误 `REQ-MOD-001 用户新增`,实际 Controller.java 注释正确为 `REQ-USR-001`;plan 文档同步修正即可。
  26 +
  27 +## 反例 / 测试覆盖缺口
  28 +
  29 +1. spec § 验收 #6(iStaffId 已软删除)IT 层未覆盖(仅 service mock 单测)。
  30 +2. spec § 验收 #4 sUserNo 唯一冲突 IT 层未直接覆盖(仅 sUserName 冲突的 IT)。
  31 +3. spec § 业务规则 8 「DuplicateKeyException 端到端映射 40921」无 IT 触发路径(@Transactional 包裹下不易触发;service mock 已覆盖,gap 可接受)。
  32 +4. docs/05 § REQ-USR-001 错误码段位 40020 与 spec 落地 40010 不一致(spec § 错误码映射注释提到 docs/05 后续 sweep;本 REQ 不阻塞)。
  33 +5. docs/superpowers 文档残留 `bSelected` 概念,与代码实现脱节(must_fix #2)。
  34 +
  35 +**round 1 修复范围**:仅修 must_fix #2(spec/plan 文档清洁),代码不动;下一 round verify 重跑全量后预期 approve。
docs/superpowers/specs/2026-05-06-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-06
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-001 — 用户新增
  8 +
  9 +## 目标
  10 +
  11 +实现后端 `POST /api/users` 接口:录入新用户基本信息 + 员工关联(可选)+ 权限组关联,密码默认 `666666` 经 BCrypt 哈希后落库;返回 `iIncrement` + 用户 VO(不含密码哈希)。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**接口**:`POST /api/users`,Content-Type `application/json`。
  16 +
  17 +**Request body**(`UserCreateDTO`)字段:
  18 +
  19 +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 |
  20 +|---|---|---|---|---|
  21 +| `sUserNo` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一 | `tUser.sUserNo` |
  22 +| `sUserName` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一(登录账号) | `tUser.sUserName` |
  23 +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement` | `tUser.iStaffId` |
  24 +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | `tUser.sUserType` |
  25 +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | `tUser.sLanguage` |
  26 +| `bCanModifyDocs` | Boolean | 否 | 默认 `false` | `tUser.bCanModifyDocs` |
  27 +| `permissionCategoryIds` | List<Integer> | 否 | 每个元素须指向存在且未软删除的 `tPermissionCategory.iIncrement`;可空数组(无授权) | 写入 `tUserPermission` 关联表 |
  28 +
  29 +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:CREATE`。沿用 module_mod 的 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。
  30 +
  31 +> **密码不在 DTO 里**:默认 `666666` 经 `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder` 哈希后落库。BCrypt 已经在 spring-boot-starter-security 中包含,无需新增依赖。
  32 +
  33 +## 输出 / 结果
  34 +
  35 +**HTTP 200,响应体**:
  36 +
  37 +```json
  38 +{
  39 + "code": 200,
  40 + "message": "操作成功",
  41 + "data": {
  42 + "iIncrement": 12,
  43 + "sUserNo": "u001",
  44 + "sUserName": "alice",
  45 + "iStaffId": 7,
  46 + "sUserType": "普通用户",
  47 + "sLanguage": "zh",
  48 + "bCanModifyDocs": false,
  49 + "tCreateDate": "2026-05-06T10:30:00",
  50 + "bDeleted": false,
  51 + "permissionCategoryIds": [1, 2, 3]
  52 + },
  53 + "timestamp": 1746528600000
  54 +}
  55 +```
  56 +
  57 +新建 VO `UserVO`:字段 `iIncrement` / `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` / `tCreateDate` / `bDeleted` / `permissionCategoryIds`(聚合自 tUserPermission)。
  58 +
  59 +**不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tLastLoginDate` / `tDeletedDate` / `sDeletedBy`。
  60 +
  61 +## 业务规则
  62 +
  63 +1. **唯一性**:`sUserNo` 与 `sUserName` 在 `bDeleted=0` 范围内系统内全局唯一。冲突 → `BizException(USR_USER_NAME_OR_NO_DUP)` (40921)。
  64 +2. **职员校验**:若 `iStaffId` 非空,必须 `selectById(iStaffId)` 存在且 `bDeleted=0`;不存在或已删 → `BizException(STAFF_NOT_FOUND)` (40421)。
  65 +3. **权限分类校验**:若 `permissionCategoryIds` 非空,每个 id 都要存在且未软删除(一次 `selectBatchIds` 一次性校验);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。
  66 +4. **密码哈希**:固定初始密码字符串 `"666666"` → `BCryptPasswordEncoder().encode("666666")` → 落 `tUser.sPasswordHash`。`BCryptPasswordEncoder` 注册为 Spring Bean(`PasswordConfig`)便于 REQ-USR-004 复用。
  67 +5. **关联表写入**:`tUserPermission` 按 `permissionCategoryIds` 逐条 `insert`(每条 `iUserId=新用户 id` / `iCategoryId=对应分类 id` / `tCreateDate=now`;docs/03 修订版无 bSelected 列,**关联记录存在即「已选」**)。
  68 +6. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=LocalDateTime.now()`、`tLastLoginDate=NULL`、`sCreatedBy=NULL`(多租户/登录上下文未引入)、`sBrandsId=NULL`、`sSubsidiaryId=NULL`、`sId=NULL`。
  69 +7. **事务边界**:`@Transactional(rollbackFor = Exception.class)` 包住 用户校验 + 用户 insert + 权限关联批量 insert 三步;任一失败整体回滚。
  70 +8. **并发兜底**:DB 唯一索引 `uk_user_no` / `uk_user_name` 兜底唯一性;service 捕 `DuplicateKeyException` 映射为 `USR_USER_NAME_OR_NO_DUP`。
  71 +
  72 +## 边界与约束
  73 +
  74 +### 鉴权策略
  75 +
  76 +沿用 module_mod 的 SecurityConfig permitAll。注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。
  77 +
  78 +### 错误码映射
  79 +
  80 +| 场景 | 错误码 | ErrorCode 枚举 |
  81 +|---|---|---|
  82 +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) |
  83 +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(**新增**) |
  84 +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(**新增**) |
  85 +| `sUserNo` / `sUserName` 唯一冲突 | 40921 | `USR_USER_NAME_OR_NO_DUP`(**新增**) |
  86 +| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
  87 +
  88 +> docs/05 § REQ-USR-001 中列的错误码 `40020` / `40921` / `40421` / `40422` —— 段位约定 `40020` 是 USR 模块的参数错(非 MOD 模块的 `40010`)。本 spec **统一沿用 `40010` 作为参数错(与 GlobalExceptionHandler 现有映射一致)**,避免在两套段位里折腾。docs/05 后续 sweep 时再统一对齐。
  89 +
  90 +### 性能 / 并发
  91 +
  92 +- 单条用户 + 至多 N 条权限关联 insert,预期低并发。`uk_user_no` / `uk_user_name` 唯一约束兜底。
  93 +- `permissionCategoryIds` 批量校验用 `selectBatchIds` 单次 SQL,O(1) round-trip。
  94 +
  95 +### 字符集 / 长度
  96 +
  97 +- utf8mb4,允许中文姓名 / 用户名(虽然多数业务侧用英文)。
  98 +- 长度超 schema 上限 → 视为参数错 40010。
  99 +
  100 +### 与 docs/04 § 1.4 / 3.5 一致性
  101 +
  102 +- 异常走 GlobalExceptionHandler。
  103 +- BCryptPasswordEncoder bean 不硬编码 strength(默认 10),从 application.yml 读取 strength(暂留默认)。
  104 +
  105 +### 已知技术债
  106 +
  107 +- **`sCreatedBy=NULL`**:REQ-USR-004 引入登录上下文后回填。
  108 +- **多租户字段 NULL**:与 module_mod 一致,REQ-USR-004 后由拦截器注入。
  109 +
  110 +## 依赖的 schema 表 / 字段
  111 +
  112 +**写表**:`tUser`、`tUserPermission`
  113 +
  114 +**读表**:`tStaff`(关联校验 / 后续 REQ-USR-003 列表 join)、`tPermissionCategory`(关联校验 / 列表只读字典)
  115 +
  116 +| `tUser` 字段 | 落库逻辑 |
  117 +|---|---|
  118 +| `iIncrement` | DB AUTO_INCREMENT |
  119 +| `sUserNo` / `sUserName` | 入参(必填,唯一) |
  120 +| `iStaffId` | 入参(可选;FK 校验通过的 `tStaff.iIncrement`) |
  121 +| `sUserType` / `sLanguage` | 入参(必填,枚举) |
  122 +| `bCanModifyDocs` | 入参(可选,默认 false) |
  123 +| `sPasswordHash` | BCrypt("666666") |
  124 +| `tCreateDate` | LocalDateTime.now() |
  125 +| `tLastLoginDate` / `sCreatedBy` / 多租户 / `sId` / `bDeleted` 三件套 | 见 § 业务规则 6 |
  126 +
  127 +| `tUserPermission` 字段 | 落库逻辑 |
  128 +|---|---|
  129 +| `iIncrement` | DB AUTO_INCREMENT |
  130 +| `iUserId` | 新用户 iIncrement |
  131 +| `iCategoryId` | dto.permissionCategoryIds[i] |
  132 +| `tCreateDate` | LocalDateTime.now() |
  133 +| `sCreatedBy` | NULL(REQ-USR-004 后回填) |
  134 +| 多租户 / `sId` | NULL |
  135 +
  136 +**索引利用**:
  137 +- `uk_user_no` / `uk_user_name`(UNIQUE):用户唯一性预检 + 兜底
  138 +- `uk_user_perm` (UNIQUE iUserId+iCategoryId):防重复授权(应用层不会触发,DB 兜底)
  139 +
  140 +**外键**:
  141 +- `fk_user_staff`(tUser.iStaffId → tStaff.iIncrement):应用层先查再 insert,避免直接抛 SQL 完整性异常
  142 +- `fk_up_user`(tUserPermission.iUserId → tUser.iIncrement):CASCADE,本接口无需关心
  143 +- `fk_up_category`(tUserPermission.iCategoryId → tPermissionCategory.iIncrement):应用层先 selectBatchIds 再 insert
  144 +
  145 +## 依赖的接口
  146 +
  147 +无(本接口独立工作)。
  148 +
  149 +REQ-USR-002 / 003 / 004 都会读 tUser,但不依赖本接口运行时 — 仅依赖本接口建立的数据。
  150 +
  151 +## 验收标准
  152 +
  153 +### 功能正确性
  154 +
  155 +1. **正向 — 最小字段(无 staff、无权限)**:传入 sUserNo / sUserName / sUserType / sLanguage 必填,返回 200 + `data.iIncrement` + 默认 bCanModifyDocs=false / permissionCategoryIds=[];DB 中 sPasswordHash 为 BCrypt 哈希(不等于 "666666" 明文)。
  156 +2. **正向 — 含 staff + 权限**:传入合法 iStaffId + 3 个 permissionCategoryIds,返回 200;DB tUser.iStaffId 等于入参;tUserPermission 中存在 3 条关联(iUserId=新用户)。
  157 +3. **唯一性冲突 — sUserName**:先建一个 sUserName=alice 的用户,再用同 sUserName 提交,返回 40921。
  158 +4. **唯一性冲突 — sUserNo**:同上,sUserNo 冲突。
  159 +5. **iStaffId 不存在**:传入 iStaffId=999999,返回 40421。
  160 +6. **iStaffId 已软删除**:先建 staff 后置 bDeleted=1,再 POST,返回 40421。
  161 +7. **permissionCategoryIds 任一不存在**:传入 [1, 999999],返回 40422,且 DB 中 tUser 与 tUserPermission 都未写入(事务回滚)。
  162 +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。
  163 +9. **空 permissionCategoryIds**:传 `[]` 或不传该字段,正向通过(无关联记录)。
  164 +10. **密码哈希不可逆**:直接读 DB sPasswordHash,断言以 `$2a$` 或 `$2b$` 开头(BCrypt 标准前缀),且不含 "666666" 明文。
  165 +11. **响应不暴露 sPasswordHash**:jsonPath `$.data.sPasswordHash` doesNotExist。
  166 +
  167 +### 接口契约一致性
  168 +
  169 +- 响应格式 `{code, message, data, timestamp}`。
  170 +- 不回显堆栈。
  171 +
  172 +### 测试覆盖
  173 +
  174 +- **单元测试** `UserServiceImplTest`:mock UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper / BCryptPasswordEncoder
  175 + - create_minimalFields_returnsVOWithBCryptHash
  176 + - create_withStaffAndPermissions_writesAssociation
  177 + - create_duplicateUserName_throws40921
  178 + - create_duplicateUserNo_throws40921
  179 + - create_staffNotFound_throws40421
  180 + - create_staffSoftDeleted_throws40421
  181 + - create_permissionCategoryNotFound_throws40422
  182 + - create_emptyPermissionCategoryIds_doesNotInsertAssociation
  183 + - create_concurrentDuplicate_dupKeyException_mappedTo40921
  184 +- **集成测试** `UserControllerIT`:
  185 + - post_minimalFields_returns200
  186 + - post_withStaffAndPermissions_returns200_andDbAssociated
  187 + - post_duplicateUserName_returns40921
  188 + - post_staffNotFound_returns40421
  189 + - post_permissionCategoryNotFound_returns40422
  190 + - post_passwordHashedInDb_notPlaintext
  191 + - post_responseExcludesSPasswordHash
  192 +
  193 +### 代码与文档
  194 +
  195 +- `// REQ-USR-001` 注释贴在 Controller / Service / 新增 ErrorCode / DTO / VO。
  196 +- 提交按 `feat(usr): <subject> REQ-USR-001` 规范。
  197 +- 不引入 docs/04 § 零 技术栈外的依赖(BCryptPasswordEncoder 已在 spring-boot-starter-security 中)。