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 | 否 | 非空时需存在于 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):
{
"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<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-parent3.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)
- parent:
-
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/**/*.xmlapp.security.default-password=666666app.security.max-login-fail=5app.security.lock-minutes=30app.security.jwt.secret=${JWT_SECRET}app.security.jwt.access-ttl-hours=24server.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<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 bodycode=40101、message="未认证" -
common/security/RestAccessDeniedHandler.java implements AccessDeniedHandler:已认证但无权限 → HTTP 403 + Result bodycode=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) - 暴露
PasswordEncoderbean =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 行存在
验收标准
接口契约
- ✅ 合法请求 POST
/api/usr/user(带 admin token)→ HTTP 200,code=200,data.userId为正整数,data.userCode与入参一致;tUser多 1 行、tUserPermission多len(permissionIds)行 - ✅ 入参
userName与已有重复 → HTTP 200,code=40002,tUser行数不变 - ✅ 入参
userCode与已有重复 → HTTP 200,code=40003,tUser行数不变 - ✅ 入参
employeeId为不存在的 ID →code=40004 - ✅ 入参
employeeId指向已作废员工(iIsDisabled=1)→code=40004 - ✅ 入参
permissionIds含不存在的 ID →code=40005,tUser不写入(整体事务回滚) - ✅ 入参缺
userName(必填)→code=40001,message含字段错误信息 - ✅ 入参
userType取值不合法(如"FOO")→code=40001 - ✅ 带普通用户 token(authorities 不含
usr:user:create)→code=40301 - ✅ 不带 Authorization 头 → HTTP 401 + Result body
code=40101、message="未认证"(由自定义AuthenticationEntryPoint统一转译为 Result 包装;docs/05 § 错误码 401xx 段位语义)
数据正确性
- ✅ 写入的
sPasswordHash以$2a$/$2b$BCrypt 前缀开头,长度 60,不等于明文 - ✅ 入参不带
password→ 写入的 hash 用 BCrypt 哈希666666后能matches通过 - ✅ 写入的
sCreatedBy等于 token 中的 admin userName - ✅ 写入的
sBrandsId/sSubsidiaryId等于 token 中的对应字段 - ✅ 响应 body、错误响应、应用日志中不出现明文
password字段值
基础设施
- ✅
mvn spring-boot:run启动成功,Flyway 自动 apply V1 + V2,启动日志见tUser中存在admin行 - ✅
mvn test测试套件通过(含本 REQ 集成测试) - ✅
GET /v3/api-docs或/swagger-ui.html可访问(springdoc 暴露),看到POST /api/usr/user接口签名
错误响应格式
- ✅ 任何错误响应 body 不含 stacktrace / class name / SQL 文本;只含
code/message/data:null/timestamp