--- req_id: REQ-USR-001 date: 2026-05-13 module: USR-用户管理 --- # Spec: REQ-USR-001 — 增加用户 ## 目标 实现 `POST /api/usr/user` 增加用户接口:管理员(或被授予 `usr:user:create` 权限的账号)提交合法字段后,写入 `tUser` 主记录 + `tUserPermission` 关联授权,初始密码以哈希形式存储,账号立即生效可用。 本 REQ 作为后端阶段第一个落地的功能,**附带一次性建立后端 Spring Boot 工程骨架与通用基础设施**(详见 § 附带基础设施),供本模块后续 REQ-USR-002/003/004 复用。 ## 输入 / 触发 ### HTTP 入口 - Method: `POST` - Path: `/api/usr/user` - Auth: 需要(JWT, `Authorization: Bearer `) - Permission: `usr:user:create` ### 请求体 `UserCreateDTO`(JSON) | 字段 | 类型 | 必填 | 校验 | 说明 | |---|---|---|---|---| | `userCode` | String | 是 | 非空、长度 ≤ 32、与已有不重复 | 用户号(业务唯一编码),写 `tUser.sUserCode` | | `userName` | String | 是 | 非空、长度 ≤ 50、与已有不重复 | 用户名(登录标识,全局唯一),写 `tUser.sUserName` | | `employeeId` | Integer | 否 | 非空时需存在于 `tEmployee` 且 `iIsDisabled=0` | 写 `tUser.iEmployeeId` | | `userType` | String | 是 | 枚举 `NORMAL` / `ADMIN` | 写 `tUser.sUserType` | | `language` | String | 是 | 枚举 `zh-CN` / `en` / `zh-TW` | 写 `tUser.sLanguage` | | `canEditDoc` | Boolean | 是 | — | 写 `tUser.iCanEditDoc`(true→1, false→0) | | `password` | String | 否 | 非空时长度 1–64 | 明文密码;缺省 → 用配置 `app.security.default-password`(默认 `666666`)。后端 BCrypt 哈希后写 `tUser.sPasswordHash` | | `permissionIds` | Integer[] | 否 | 元素需存在于 `tPermission` 且 `iIsDisabled=0` | 权限分类主键数组,对应写入 `tUserPermission` | ### 隐式上下文(来自 JWT/SecurityContext) - 当前操作员的 `userName` → 写 `tUser.sCreatedBy` - 当前操作员的 `brandsId` / `subsidiaryId` → 写 `tUser.sBrandsId` / `tUser.sSubsidiaryId`(**多租户继承策略**:新用户默认归属创建者所在品牌+子公司) - 当前操作员的 authorities 必须包含 `usr:user:create`,否则返回 `40301` ## 输出 / 结果 成功响应(HTTP 200): ```json { "code": 200, "message": "操作成功", "data": { "userId": 12, "userCode": "U2026051301" }, "timestamp": 1715619600000 } ``` - `data.userId` = 新写入 `tUser.iIncrement`(int) - `data.userCode` = 写入的 `sUserCode`,方便前端立刻回显(对应 REQ 输出表 1「用户号」) - **password 字段在任何响应、日志、堆栈中都不得出现** ### 副作用 - `tUser` 新增 1 行 - `tUserPermission` 新增 `len(permissionIds)` 行(permissionIds 为空/未传时不写入关联) ## 业务规则 ### B-001 唯一性 - `userName` 命中 `uk_tUser_sUserName` → `40002` - `userCode` 命中 `uk_tUser_sUserCode` → `40003` - 唯一性优先在 Service 层 `selectCount` 预检(更友好的错误信息),但 DB 唯一索引兜底;并发场景下捕获 `DuplicateKeyException` 转译为 `40002` / `40003`(按命中索引名区分) ### B-002 外键 / 字典存在性 - `employeeId` 非空且 (`tEmployee` 中无此 ID 或 `iIsDisabled=1`) → `40004` - `permissionIds` 中任一元素 (`tPermission` 中无此 ID 或 `iIsDisabled=1`) → `40005` ### B-003 密码处理 - 明文密码:DTO 传入或取 `app.security.default-password` - 用 Spring Security `BCryptPasswordEncoder.encode(password)` 哈希 → 写 `sPasswordHash` - 不存明文,不打日志,不回显 ### B-004 隐式字段写入 - `sCreatedBy` = SecurityContext 当前操作员 `userName` - `sBrandsId` / `sSubsidiaryId` = SecurityContext 当前操作员的同名字段 - `iIsDisabled` = 0 - `iLoginFailCount` = 0 - `tLockedUntil` = NULL - `tLastLoginDate` = NULL - `tCreateDate` / `tUpdateDate` 由 MySQL DEFAULT / ON UPDATE CURRENT_TIMESTAMP 自动赋值 - `sId` 标准列暂置 NULL(业务 ID 后续可由全局 ID 生成器补;本 REQ 不强求) ### B-005 事务边界 - Service 实现类方法标 `@Transactional(rollbackFor = Exception.class)` - `tUser` 插入 → 获取自增主键 `iIncrement` → 循环/批量插入 `tUserPermission`,任一失败整体回滚 - Controller 不打事务 ### B-006 权限校验 - 用 Spring Security 的 `@PreAuthorize("hasAuthority('usr:user:create')")` 注解放在 Controller 方法 - 当前账号 authorities 不含该权限 → Spring Security 抛 `AccessDeniedException`,全局异常处理器转译为 `40301` ### B-007 参数校验 - DTO 用 Jakarta Bean Validation 注解(`@NotBlank` / `@Size` / `@Pattern` / `@NotNull` 等) - `@Valid` 触发,未通过 → `MethodArgumentNotValidException` → 全局异常处理器转译为 `40001`,`message` 取首个 fieldError 信息 ## 边界与约束 - 包路径:`com.xly.test4.module.usr.{controller,service,service.impl,mapper,entity,dto,vo}` - Mapper XML:`backend/src/main/resources/mapper/usr/{UserMapper.xml, UserPermissionMapper.xml}` - DTO/VO 字段用小驼峰;entity 字段与表列名 1:1(匈牙利前缀保留,如 `iIncrement` / `sUserName`)。通过 MyBatis-Plus `@TableField` 注解显式映射 - DTO ↔ Entity ↔ VO 转换走 MapStruct(`UserConverter` 接口) - 接口响应统一 `Result` 包装(code/message/data/timestamp);错误响应禁止回显堆栈,堆栈只进 Logback - 唯一性预检 + DB 兜底两道防线(避免单纯依赖 SELECT 再 INSERT 的 TOCTOU 漏洞) - 测试时通过 `JwtTokenProvider` 直接签发带种子 admin 上下文的 token;不依赖 REQ-USR-004 登录接口 - 错误码体系**以 docs/05 为准**(数字字符串如 `"40001"`),与 docs/04 § 1.3 的 `-` 描述存在差异,本 REQ 不修订 docs/04,后续可在统一文档时处理 ## 依赖的 schema 表 / 字段 ### 写入 - `tUser`:插入新行,字段见 § B-004 - `tUserPermission`:每个 permissionId 一行,字段 `iUserId` / `iPermissionId` / `sGrantedBy`(取当前操作员 userName)/ `sBrandsId` / `sSubsidiaryId` ### 读取(校验用) - `tEmployee`:按 `iIncrement` 查存在性 + `iIsDisabled=0` - `tPermission`:按 `iIncrement IN (…)` 批量查存在性 + `iIsDisabled=0` ### 不涉及 - `tCompany`:仅 REQ-USR-004 登录页用作版本下拉,本 REQ 不读不写 ## 依赖的接口 - **本 REQ 提供**:`POST /api/usr/user`(见 docs/05 § REQ-USR-001) - **本 REQ 不依赖任何其他业务接口**(鉴权链路通过 Spring Security filter 自动消费 JWT,不算接口依赖) ## 附带基础设施 > 这些基础组件本 REQ 一次性建好,供 REQ-USR-002/003/004 直接复用。后续 REQ 的 spec 不再重复声明这些。 ### 工程骨架 - `backend/pom.xml` - parent: `spring-boot-starter-parent` 3.x - 依赖:`spring-boot-starter-web`、`spring-boot-starter-security`、`spring-boot-starter-validation`、`mybatis-plus-spring-boot3-starter`、`flyway-core` + `flyway-mysql`、`mysql-connector-j`、`io.jsonwebtoken:jjwt-api`+`jjwt-impl`+`jjwt-jackson`(或 `nimbus-jose-jwt`,二选一;本设计选 jjwt 0.12.x)、`mapstruct` + `lombok` + `lombok-mapstruct-binding`、`springdoc-openapi-starter-webmvc-ui`、`spring-boot-starter-test` + `spring-security-test`(test scope) - `backend/src/main/java/com/xly/test4/Application.java`:`@SpringBootApplication` 入口 - `backend/src/main/resources/application.yml`: - `spring.datasource.url/username/password`:从环境变量注入(`${DB_HOST}/${DB_PORT}/${DB_USER}/${DB_PASSWORD}/${DB_SCHEMA}`),匹配 `.env.local` - `spring.flyway.enabled=true`、`spring.flyway.locations=filesystem:../sql/migrations`(直接指向仓库根 `sql/migrations/`,与 setup-test-db.sh 注释及 CLAUDE.md § Schema 演化规约 的 SSoT 路径一致;Maven 启动 / IDE 启动工作目录均为 `backend/`,相对路径成立) - `mybatis-plus.mapper-locations=classpath:mapper/**/*.xml` - `app.security.default-password=666666` - `app.security.max-login-fail=5` - `app.security.lock-minutes=30` - `app.security.jwt.secret=${JWT_SECRET}` - `app.security.jwt.access-ttl-hours=24` - `server.port=8080` - `backend/src/main/resources/application-dev.yml`:dev profile 覆盖(如 Logback DEBUG) ### Common 层 - `common/response/Result.java` - 字段:`code: int`、`message: String`、`data: T`、`timestamp: long` - 静态方法:`Result.success(T data)` / `Result.fail(int code, String message)` - `common/response/ResultCode.java`:常量集合(200 / 40001 / 40002 / 40003 / 40004 / 40005 / 40301 / 50000) - `common/exception/BusinessException.java extends RuntimeException`:字段 `code: int`、`message: String` - `common/exception/GlobalExceptionHandler.java`(`@RestControllerAdvice`): - `BusinessException` → `Result.fail(e.code, e.message)` - `MethodArgumentNotValidException` → `Result.fail(40001, firstFieldErrorMessage)` - `AccessDeniedException` → `Result.fail(40301, "权限不足")` - `DuplicateKeyException` → 按命中索引名识别(`uk_tUser_sUserName` / `uk_tUser_sUserCode`)转 40002/40003;否则 50000 - 兜底 `Exception` → `Result.fail(50000, "系统繁忙,请稍后重试")`,堆栈进 Logback - `common/security/JwtTokenProvider.java`: - 签发:输入 `userName` / `userId` / `brandsId` / `subsidiaryId` / `authorities: List` → 返回 `accessToken`,HS256 签名,`exp = now + 24h` - 解析:输入 `token` → 返回 `Claims`(含上述字段),过期/篡改抛 `JwtAuthenticationException`(自定义) - `common/security/JwtAuthenticationFilter.java extends OncePerRequestFilter`: - 从 `Authorization: Bearer ` 头解析,构造 `UsernamePasswordAuthenticationToken` 并塞入 `SecurityContextHolder` - principal 用自定义 `CurrentUser` 对象(持有 userId/userName/brandsId/subsidiaryId) - 失败 → 不直接拒绝,交给 Security 链后续 `AuthenticationEntryPoint` 决定 - `common/security/CurrentUser.java`:值对象,字段 `userId/userName/brandsId/subsidiaryId/authorities` - `common/security/CurrentUserContext.java`:静态工具,封装 `SecurityContextHolder.getContext().getAuthentication().getPrincipal()` 转 `CurrentUser` - `common/security/RestAuthenticationEntryPoint.java implements AuthenticationEntryPoint`:未认证(无 token / token 非法)→ HTTP 401 + Result body `code=40101`、`message="未认证"` - `common/security/RestAccessDeniedHandler.java implements AccessDeniedHandler`:已认证但无权限 → HTTP 403 + Result body `code=40301`、`message="权限不足"`(与 § B-006 一致;Spring Security `@PreAuthorize` 抛出的 `AccessDeniedException` 走全局异常 handler,本处仅兜底 filter 层抛出的同类异常) - `common/security/SecurityConfig.java`(`@EnableMethodSecurity`): - `SecurityFilterChain`:`/api/auth/login`、`/v3/api-docs/**`、`/swagger-ui/**` 白名单,其余 `/api/**` 需鉴权;`csrf().disable()`、`sessionCreationPolicy=STATELESS`、注册 `JwtAuthenticationFilter` 在 `UsernamePasswordAuthenticationFilter` 之前;`exceptionHandling().authenticationEntryPoint(RestAuthenticationEntryPoint).accessDeniedHandler(RestAccessDeniedHandler)` - 暴露 `PasswordEncoder` bean = `BCryptPasswordEncoder` - `common/config/MybatisPlusConfig.java`:注册分页插件 `PaginationInnerInterceptor`(后续 REQ-USR-003 用) ### Migration - `sql/migrations/V2__seed_admin_and_permissions.sql`: - 插入 `tPermission` 行(≥ 4 行,覆盖本模块所有 REQ 需要的权限码): - (sCategory='usr:user:create', sCategoryName='新增用户') - (sCategory='usr:user:update', sCategoryName='修改用户') - (sCategory='usr:user:list', sCategoryName='查询用户') - (sCategory='usr:user:assign-role', sCategoryName='分配用户角色') - 插入 `tUser` 行:种子超管 - `sUserName='admin'`、`sUserCode='ADMIN001'`、`sUserType='ADMIN'`、`sLanguage='zh-CN'`、`iCanEditDoc=1`、`iIsDisabled=0` - `sBrandsId='BR-DEFAULT'`、`sSubsidiaryId='SUB-DEFAULT'` - `sPasswordHash` = 预先 BCrypt 哈希过的 `admin` 字面值(migration 内硬编码 BCrypt 字符串,生成方式由 spec 调用方决定;推荐用本地 `BCryptPasswordEncoder` 提前生成一次落 SQL) - 插入 `tUserPermission` 行:admin × 上述 4 个权限分类,全部授权 > **migration 命名说明**:项目当前已有 `V1__initial_schema.sql`(A4 生成),新增的种子 migration 命名为 `V2__seed_admin_and_permissions.sql`,与 CLAUDE.md § Schema 演化规约 一致。 ### 验证策略(与测试无关,但属于本 REQ 配套) - 在 `Application.java` 启动后通过 Spring Boot Actuator `/actuator/health`(可选)或集成测试 `@SpringBootTest` 启动验证 Flyway apply 通过 + tUser/admin 行存在 ## 验收标准 ### 接口契约 1. ✅ 合法请求 POST `/api/usr/user`(带 admin token)→ HTTP 200,`code=200`,`data.userId` 为正整数,`data.userCode` 与入参一致;`tUser` 多 1 行、`tUserPermission` 多 `len(permissionIds)` 行 2. ✅ 入参 `userName` 与已有重复 → HTTP 200,`code=40002`,`tUser` 行数不变 3. ✅ 入参 `userCode` 与已有重复 → HTTP 200,`code=40003`,`tUser` 行数不变 4. ✅ 入参 `employeeId` 为不存在的 ID → `code=40004` 5. ✅ 入参 `employeeId` 指向已作废员工(`iIsDisabled=1`)→ `code=40004` 6. ✅ 入参 `permissionIds` 含不存在的 ID → `code=40005`,`tUser` 不写入(整体事务回滚) 7. ✅ 入参缺 `userName`(必填)→ `code=40001`,`message` 含字段错误信息 8. ✅ 入参 `userType` 取值不合法(如 `"FOO"`)→ `code=40001` 9. ✅ 带普通用户 token(authorities 不含 `usr:user:create`)→ `code=40301` 10. ✅ 不带 Authorization 头 → HTTP 401 + Result body `code=40101`、`message="未认证"`(由自定义 `AuthenticationEntryPoint` 统一转译为 Result 包装;docs/05 § 错误码 401xx 段位语义) ### 数据正确性 11. ✅ 写入的 `sPasswordHash` 以 `$2a$` / `$2b$` BCrypt 前缀开头,长度 60,不等于明文 12. ✅ 入参不带 `password` → 写入的 hash 用 BCrypt 哈希 `666666` 后能 `matches` 通过 13. ✅ 写入的 `sCreatedBy` 等于 token 中的 admin userName 14. ✅ 写入的 `sBrandsId` / `sSubsidiaryId` 等于 token 中的对应字段 15. ✅ 响应 body、错误响应、应用日志中不出现明文 `password` 字段值 ### 基础设施 16. ✅ `mvn spring-boot:run` 启动成功,Flyway 自动 apply V1 + V2,启动日志见 `tUser` 中存在 `admin` 行 17. ✅ `mvn test` 测试套件通过(含本 REQ 集成测试) 18. ✅ `GET /v3/api-docs` 或 `/swagger-ui.html` 可访问(springdoc 暴露),看到 `POST /api/usr/user` 接口签名 ### 错误响应格式 19. ✅ 任何错误响应 body 不含 stacktrace / class name / SQL 文本;只含 `code` / `message` / `data:null` / `timestamp`