2026-05-13-REQ-USR-001.md 14.7 KB

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 <accessToken>
  • Permission: usr:user:create

请求体 UserCreateDTO(JSON)

字段 类型 必填 校验 说明
userCode String 非空、长度 ≤ 32、与已有不重复 用户号(业务唯一编码),写 tUser.sUserCode
userName String 非空、长度 ≤ 50、与已有不重复 用户名(登录标识,全局唯一),写 tUser.sUserName
employeeId Integer 非空时需存在于 tEmployeeiIsDisabled=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[] 元素需存在于 tPermissioniIsDisabled=0 权限分类主键数组,对应写入 tUserPermission

隐式上下文(来自 JWT/SecurityContext)

  • 当前操作员的 userName → 写 tUser.sCreatedBy
  • 当前操作员的 brandsId / subsidiaryId → 写 tUser.sBrandsId / tUser.sSubsidiaryId多租户继承策略:新用户默认归属创建者所在品牌+子公司)
  • 当前操作员的 authorities 必须包含 usr:user:create,否则返回 40301

输出 / 结果

成功响应(HTTP 200):

{
  "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_sUserName40002
  • userCode 命中 uk_tUser_sUserCode40003
  • 唯一性优先在 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 → 全局异常处理器转译为 40001message 取首个 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<T> 包装(code/message/data/timestamp);错误响应禁止回显堆栈,堆栈只进 Logback
  • 唯一性预检 + DB 兜底两道防线(避免单纯依赖 SELECT 再 INSERT 的 TOCTOU 漏洞)
  • 测试时通过 JwtTokenProvider 直接签发带种子 admin 上下文的 token;不依赖 REQ-USR-004 登录接口
  • 错误码体系以 docs/05 为准(数字字符串如 "40001"),与 docs/04 § 1.3 的 <MOD>-<NNN> 描述存在差异,本 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-webspring-boot-starter-securityspring-boot-starter-validationmybatis-plus-spring-boot3-starterflyway-core + flyway-mysqlmysql-connector-jio.jsonwebtoken:jjwt-api+jjwt-impl+jjwt-jackson(或 nimbus-jose-jwt,二选一;本设计选 jjwt 0.12.x)、mapstruct + lombok + lombok-mapstruct-bindingspringdoc-openapi-starter-webmvc-uispring-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=truespring.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: intmessage: Stringdata: Ttimestamp: 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: intmessage: String
  • common/exception/GlobalExceptionHandler.java@RestControllerAdvice):
    • BusinessExceptionResult.fail(e.code, e.message)
    • MethodArgumentNotValidExceptionResult.fail(40001, firstFieldErrorMessage)
    • AccessDeniedExceptionResult.fail(40301, "权限不足")
    • DuplicateKeyException → 按命中索引名识别(uk_tUser_sUserName / uk_tUser_sUserCode)转 40002/40003;否则 50000
    • 兜底 ExceptionResult.fail(50000, "系统繁忙,请稍后重试"),堆栈进 Logback
  • common/security/JwtTokenProvider.java
    • 签发:输入 userName / userId / brandsId / subsidiaryId / authorities: List<String> → 返回 accessToken,HS256 签名,exp = now + 24h
    • 解析:输入 token → 返回 Claims(含上述字段),过期/篡改抛 JwtAuthenticationException(自定义)
  • common/security/JwtAuthenticationFilter.java extends OncePerRequestFilter
    • Authorization: Bearer <token> 头解析,构造 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=40101message="未认证"
  • common/security/RestAccessDeniedHandler.java implements AccessDeniedHandler:已认证但无权限 → HTTP 403 + Result body code=40301message="权限不足"(与 § 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、注册 JwtAuthenticationFilterUsernamePasswordAuthenticationFilter 之前;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=1iIsDisabled=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=200data.userId 为正整数,data.userCode 与入参一致;tUser 多 1 行、tUserPermissionlen(permissionIds)
  2. ✅ 入参 userName 与已有重复 → HTTP 200,code=40002tUser 行数不变
  3. ✅ 入参 userCode 与已有重复 → HTTP 200,code=40003tUser 行数不变
  4. ✅ 入参 employeeId 为不存在的 ID → code=40004
  5. ✅ 入参 employeeId 指向已作废员工(iIsDisabled=1)→ code=40004
  6. ✅ 入参 permissionIds 含不存在的 ID → code=40005tUser 不写入(整体事务回滚)
  7. ✅ 入参缺 userName(必填)→ code=40001message 含字段错误信息
  8. ✅ 入参 userType 取值不合法(如 "FOO")→ code=40001
  9. ✅ 带普通用户 token(authorities 不含 usr:user:create)→ code=40301
  10. ✅ 不带 Authorization 头 → HTTP 401 + Result body code=40101message="未认证"(由自定义 AuthenticationEntryPoint 统一转译为 Result 包装;docs/05 § 错误码 401xx 段位语义)

数据正确性

  1. ✅ 写入的 sPasswordHash$2a$ / $2b$ BCrypt 前缀开头,长度 60,不等于明文
  2. ✅ 入参不带 password → 写入的 hash 用 BCrypt 哈希 666666 后能 matches 通过
  3. ✅ 写入的 sCreatedBy 等于 token 中的 admin userName
  4. ✅ 写入的 sBrandsId / sSubsidiaryId 等于 token 中的对应字段
  5. ✅ 响应 body、错误响应、应用日志中不出现明文 password 字段值

基础设施

  1. mvn spring-boot:run 启动成功,Flyway 自动 apply V1 + V2,启动日志见 tUser 中存在 admin
  2. mvn test 测试套件通过(含本 REQ 集成测试)
  3. GET /v3/api-docs/swagger-ui.html 可访问(springdoc 暴露),看到 POST /api/usr/user 接口签名

错误响应格式

  1. ✅ 任何错误响应 body 不含 stacktrace / class name / SQL 文本;只含 code / message / data:null / timestamp