REQ-USR-004 登录用户 — 任务级 TDD 计划(后端)
阶段:后端(backend)。作用域:
backend/**(controller / service / service.impl / mapper / DTO / VO / entity / config / 公共响应 / 安全 / REST 契约实现)。禁止写frontend/**。 上游 SSoT:specdocs/superpowers/specs/2026-06-01-REQ-USR-004.md;需求卡片docs/01-需求清单/USR-用户管理/REQ-USR-004.md;DB 设计docs/03-数据库设计文档.md;API 契约docs/05-API接口契约.md;技术规范docs/04-技术规范.md;配置config-vars.yaml。 本计划告诉 TDD 执行者做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 本 REQ 复用 REQ-USR-001/002/003 已建的modules/usr/**(UsrUser实体、UsrUserMapper extends BaseMapper<UsrUser>、UsrUserController)与common/**(Result/ResultCode/BusinessException/GlobalExceptionHandler/SecurityUtil/JwtUtil/JwtAuthenticationFilter/SecurityConfig/PasswordEncoderBean);只读usr_user/usr_company+ UPDATEusr_user.tLastLoginDate(DML 非 DDL),不新增 migration(spec § 4 / § 8 D11,二表已在V1__initial_schema.sql建好且结构与 docs/03 一致)。
Goal(目标)
实现登录认证主端点 POST /api/usr/login(放行,无需 token):接收 LoginDTO(sUserName / password / companyId,三者必填),按「基础参数校验 → 查用户 → BCrypt 比对密码 → 判禁用 → companyId 存在性校验 → 签发 JWT + 更新 tLastLoginDate」流程认证,成功返回 Result<LoginVO>(code=0,含 token 与 user{ id, sUserName, sUserType, sLanguage },绝不含 sPassword)。配套补齐只读端点 GET /api/usr/companies(放行,无参,返回 Result<List<CompanyOptionVO>> 供登录页「版本」下拉)。
防账号枚举:用户不存在与密码错误返回完全相同的 40101(统一文案);禁用用户 → 40302;参数缺失或 companyId 在 usr_company 不存在 → 40001;同一账号连续失败超阈值在冷却窗内 → 42901(登录过于频繁)。登录成功在同一事务内更新 tLastLoginDate 并清零失败计数;JWT 含过期时间(exp claim),令牌可被既有 JwtAuthenticationFilter 接受。
Architecture(架构 / 分层)
遵循 docs/04 § 1.2,根包 com.xly.erp;本 REQ 仅触及 modules/usr/** 与 common/**(错误码枚举 + 安全放行配置),不跨业务模块:
backend/src/main/java/com/xly/erp/
├── common/response/ResultCode.java # 既有枚举,新增 42901 限流码(40101/40302/40001 已存在)
├── common/config/SecurityConfig.java # 既有,放行清单追加 GET /api/usr/companies(POST /api/usr/login 已放行)
├── modules/usr/
│ ├── controller/UsrAuthController.java # 【本 REQ 新增】认证 Controller:POST /api/usr/login + GET /api/usr/companies;仅 @Valid + 委派(spec § 8 D8)
│ ├── service/UsrAuthService.java # 【本 REQ 新增】认证服务接口:login / listCompanies
│ ├── service/impl/UsrAuthServiceImpl.java # 【本 REQ 新增】认证实现(查用户→BCrypt→判禁用→companyId 校验→签发 JWT→更新登录时间;限流计数)
│ ├── mapper/UsrCompanyMapper.java # 【本 REQ 新增】UsrCompany Mapper(继承 BaseMapper,spec § 8 D9)
│ ├── mapper/UsrUserMapper.java # 既有,复用 BaseMapper 的 selectOne/updateById(无需改)
│ ├── entity/UsrCompany.java # 【本 REQ 新增】usr_company 实体(spec § 8 D9)
│ ├── entity/UsrUser.java # 既有,只读 + UPDATE tLastLoginDate(无需改)
│ ├── dto/LoginDTO.java # 【本 REQ 新增】登录入参
│ └── vo/{LoginVO,CompanyOptionVO}.java # 【本 REQ 新增】登录输出 + 公司下拉项输出(不含 sPassword)
-
跨模块:无。本 REQ 落在
modules/usr/**+ 改common/response/ResultCode.java(新增一枚错误码常量)+ 改common/config/SecurityConfig.java(放行清单加一条)。后两者属公共区改动,需在《模块完成报告》留痕「新增42901限流错误码 + 放行GET /api/usr/companies」(CLAUDE.md 跨模块改动留痕要求)。 -
数据访问:只走 Mapper(MyBatis-Plus)。按
sUserName精确查用户用UsrUserMapper.selectOne(LambdaQueryWrapper.eq(UsrUser::getSUserName, ...));更新登录时间用UsrUserMapper.updateById(仅 setiIncrement+tLastLoginDate,依赖 MP「null 字段不参与 SET」语义,不触碰其他列);按companyId校验存在用UsrCompanyMapper.selectById;公司列表用UsrCompanyMapper.selectList(null)。全部参数化(#{}/ MP 预编译)防注入。不 SELECTsPassword到响应(实体查出含sPassword供比对,但UsrUser.sPassword已标@JsonIgnore且LoginVO不含该字段,绝不外泄)。简单 CRUD,无需自定义 XML。 -
事务:
login含写(UPDATEtLastLoginDate),UsrAuthServiceImpl#login标@Transactional(rollbackFor = Exception.class)(spec § 4 / § 8 D6,更新失败整体回滚、登录视为失败);listCompanies标@Transactional(readOnly = true)。 -
安全:
SecurityConfig放行POST /api/usr/login(已放行)+GET /api/usr/companies(本 REQ 追加),登录前可访问;其余受保护接口仍走既有JwtAuthenticationFilter。密码用既有BCryptPasswordEncoderBean 比对,禁止明文。JWT 用既有JwtUtil.generateToken(userName, userType)签发(含 exp claim),密钥来自application.ymljwt.secret(引 config-varssecrets.jwt_secret),不硬编码。 -
限流:进程内(内存)按
sUserName计数的轻量限流(spec § 3 规则 8 / § 8 D7),不依赖 Redis;阈值 / 窗口读application.ymlauth.login.max-fail(默认 5)/auth.login.lock-seconds(默认 300)。计数器封装在UsrAuthServiceImpl内部(ConcurrentHashMap持有用户名→{连续失败次数, 锁定到期时间戳}),成功登录清零;线程安全用并发容器即可(单实例 MVP)。
Tech Stack(技术栈,源自 docs/04 § 零 + config-vars)
- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus(BaseMapper +
LambdaQueryWrapper,简单 CRUD 无需 XML);MySQL 8.x;Flyway 10.x(启动 / 测试启动自动 applysql/migrations/,本 REQ 不新增 migration,复用V1__initial_schema.sql,spec § 4 / § 8 D11)。 - Spring Security + JWT(既有
JwtUtil/JwtAuthenticationFilter/SecurityUtil/SecurityConfig/PasswordEncoderBean);POST /api/usr/login与GET /api/usr/companies放行。 - 根包
com.xly.erp;端口 / DB 凭据 / JWT 密钥 / 限流阈值只读config-vars.yaml/application.yml,不硬编码。 - 命令(docs/04 § 零):build
mvn -q -B -DskipTests package;lintmvn -q -B checkstyle:check;unitmvn -q -B test;e2e 无。
合同级常量(跨 task 必须一致)
- REST:
POST /api/usr/login(JSON body 绑定LoginDTO,@RequestBody);GET /api/usr/companies(无参)。二者均放行(无需 token)。 - 错误码(
ResultCode枚举,spec § 6 / docs/05;SUCCESS=0/PARAM_INVALID=40001/UNAUTHORIZED=40101/ACCOUNT_DISABLED=40302既有已存在,本 REQ 仅新增LOGIN_RATE_LIMITED=42901):-
SUCCESS=0— 成功。login→LoginVO;companies→List<CompanyOptionVO>。 -
PARAM_INVALID=40001— 参数校验失败(缺sUserName/password/companyId,或companyId在usr_company不存在)。 -
UNAUTHORIZED=40101— 认证失败(用户名或密码错误;不区分以防账号枚举,统一文案)。 -
ACCOUNT_DISABLED=40302— 账号已禁用(iIsVoid=1,禁止登录)。 -
LOGIN_RATE_LIMITED=42901— 登录过于频繁(连续失败超阈值,账号临时锁定,spec § 3 规则 8 / § 8 D7)。新增常量,message 建议「请求过于频繁,请稍后重试」。 - 401 — 受保护接口无 / 失效 token(由安全过滤器返回,非本端点产生;本登录端点放行)。
-
- 统一失败文案(spec § 3 规则 2/3):
40101走单一 message「用户名或密码错误」——用户不存在与密码错误用同一码同一文案,不暴露「账号不存在」/「密码错误」字样。可直接用BusinessException(ResultCode.UNAUTHORIZED)(默认 message 为枚举的「认证失败」),或显式传统一文案;两条失败路径必须传同一 message,由 T4 测试固化「两路径响应体逐字一致」。 - 认证判定顺序(spec § 3 规则 1 / § 8 D3,固定):①基础参数校验(
@Valid,缺失 →40001)→ ②按sUserName查usr_user,未查到 →40101→ ③先 BCrypt 比对密码,不匹配 →40101→ ④再判iIsVoid=1→40302(先验密码再返禁用码,最小化枚举面,spec § 8 D3)→ ⑤companyId存在性校验(不存在 →40001)→ ⑥签发 JWT + 更新tLastLoginDate+ 清零失败计数 + 返回LoginVO。限流判定(42901)置于流程最前(查用户前即查锁定窗,命中锁定窗直接42901,spec 验收 9:锁定窗内即使密码正确也42901)。 - JWT:复用既有
JwtUtil.generateToken(String userName, String userType),subject =sUserName、claim 含sUserName(JwtUtil.CLAIM_USER_NAME)+sUserType(JwtUtil.CLAIM_USER_TYPE)+ exp(既有实现已带expiration);返回的token不加Bearer前缀(前端按契约自行拼Authorization: Bearer <token>,与UsrUserCreateIT中"Bearer " + jwtUtil.generateToken(...)的用法一致)。companyId不写入 JWT 认证主体(spec § 3 规则 6)。 - 限流配置键(
application.yml,spec § 4 / § 8 D7):auth.login.max-fail(默认 5,达到该连续失败次数后锁定)、auth.login.lock-seconds(默认 300,锁定窗秒数)。UsrAuthServiceImpl用@Value("${auth.login.max-fail:5}")/@Value("${auth.login.lock-seconds:300}")注入。 - 软删除口径(spec § 3 规则 5 / § 8 D4):本库无独立「已删除」列,「已禁用」与「已删除」统一按
iIsVoid=1处理,均40302。 - 敏感字段(spec § 3 规则 9 / 验收 11):
LoginVO/CompanyOptionVO均不含sPassword;不SELECT *透传实体到响应;密码明文不进任何日志 / 异常 message。
关键签名(首次出现处给出,跨 task 保持一致)
-
UsrAuthService#login(LoginDTO dto)返回LoginVO。 -
UsrAuthService#listCompanies()返回List<CompanyOptionVO>。 -
UsrAuthController#login(@Valid @RequestBody LoginDTO dto)返回Result<LoginVO>。 -
UsrAuthController#listCompanies()返回Result<List<CompanyOptionVO>>。 -
UsrCompanyMapper extends BaseMapper<UsrCompany>(无自定义方法,复用selectById/selectList)。 - 复用既有:
Result.success(T);BusinessException(ResultCode)/BusinessException(ResultCode, String)(暴露getResultCode());JwtUtil.generateToken(userName, userType);PasswordEncoder.matches(raw, encoded);UsrUserMapper extends BaseMapper<UsrUser>(selectOne/updateById);实体 getter 匈牙利前缀(getSUserName/getSPassword/getIIsVoid/getSUserType/getSLanguage/getIIncrement/setTLastLoginDate)。
LoginDTO 形状(modules/usr/dto,JSON body)
字段带匈牙利前缀者(
sUserName)的 gettergetSUserName会被 Jackson/反序列化推断为属性SUserName,与契约 JSON 键sUserName不符——加@JsonProperty("sUserName")(与CreateUserDTO/UserVO同做法)锁定反序列化键名;password/companyId为常规小驼峰,无需@JsonProperty。
| 字段 | 类型 | 校验注解 | 语义 |
|---|---|---|---|
sUserName |
String |
@NotBlank,@Size(max=50)
|
登录用户名;缺失 / 空 → 40001。建议 Service 内 trim 后用于查库 |
password |
String |
@NotBlank,@Size(max=100)
|
登录密码明文;缺失 / 空 → 40001;服务端 BCrypt 比对,绝不落日志 / 回显
|
companyId |
Integer | @NotNull |
「版本」下拉选中的 usr_company.iIncrement;缺失 → 40001;仅存在性校验,不参与认证绑定 |
提供 getter/setter;
@NotBlank/@NotNull/@Size失败由既有GlobalExceptionHandler统一转40001。
LoginVO 形状(modules/usr/vo,spec § 2.2,不含 sPassword / 租户列)
| VO 字段 | 类型 | 来源 |
|---|---|---|
token |
String |
JwtUtil.generateToken 签发(不含 Bearer 前缀) |
user |
LoginVO.UserInfo(嵌套对象) |
usr_user 基础信息 |
LoginVO.UserInfo(嵌套静态类或独立 VO,二选一,保持 JSON 嵌套形状 user{...}):
| 字段 | 类型 | 来源列 |
|---|---|---|
id |
Integer | usr_user.iIncrement |
sUserName |
String | usr_user.sUserName |
sUserType |
String | usr_user.sUserType |
sLanguage |
String | usr_user.sLanguage |
UserInfo中匈牙利前缀字段(sUserName/sUserType/sLanguage)的 getter(getSUserName等)会被 Jackson 推断为SUserName等大驼峰,与契约键不符——加@JsonProperty("sUserName")等锁键(与UserVO同做法);id/token/user为常规驼峰无需注解。LoginVO提供全字段 getter/setter,implements Serializable(与Result/UserVO一致)。严格不含sPassword/password/ 租户列。
CompanyOptionVO 形状(modules/usr/vo,spec § 2.3)
| VO 字段 | 类型 | 来源列 |
|---|---|---|
id |
Integer | usr_company.iIncrement |
sCompanyName |
String | usr_company.sCompanyName |
sVersion |
String |
usr_company.sVersion(可 null) |
匈牙利前缀字段(
sCompanyName/sVersion)的 getter 加@JsonProperty锁键;id常规驼峰。提供全字段 getter/setter,implements Serializable。
UsrCompany 实体形状(modules/usr/entity,映射 usr_company,spec § 8 D9)
与
UsrUser同风格:@TableName("usr_company"),可继承既有BaseEntity(复用iIncrement/sId/ 租户列 /tCreateDate标准列,与UsrUser一致)。业务列@TableField("sCompanyName") String sCompanyName、@TableField("sVersion") String sVersion,匈牙利前缀 getter/setter(getSCompanyName/getSVersion)。先确认BaseEntity是否已含iIncrementgetter(getIIncrement)——UsrUser经getIIncrement()取主键,UsrCompany同样依赖之;若BaseEntity已提供则直接继承复用。
任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟)
业务类 commit subject 必须带
REQ-USR-004后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。
T1 — ResultCode 新增 42901 限流码
- 测试:
backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java:-
::loginRateLimitedCodeIs42901—— 断言ResultCode.LOGIN_RATE_LIMITED.getCode() == 42901且getMessage()非空(如「请求过于频繁,请稍后重试」)。 -
::existingLoginCodesPresent—— 断言既有UNAUTHORIZED.getCode()==40101、ACCOUNT_DISABLED.getCode()==40302、PARAM_INVALID.getCode()==40001(确认复用、不重复定义)。
-
- 实现:
common/response/ResultCode.java新增枚举常量LOGIN_RATE_LIMITED(42901, "请求过于频繁,请稍后重试");不改动40101/40302/40001既有常量。 - 验证:子会话跑
ResultCodeLoginTestPASS。 - commit:
feat(usr): 新增登录限流错误码 42901 REQ-USR-004
T2 — LoginDTO 入参校验 + CompanyOptionVO 输出
- 测试:
-
backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java(用jakarta.validation.Validator): -
::acceptsValidLogin——sUserName=admin、password=666666、companyId=1无违反。 -
::rejectsBlankUserName——sUserName空串 / null 违反@NotBlank。 -
::rejectsBlankPassword——password空串 / null 违反@NotBlank。 -
::rejectsNullCompanyId——companyId=null违反@NotNull。 -
::rejectsTooLongUserName——sUserName长度 51 违反@Size(max=50)。 -
::deserializesSUserNameJsonKey—— 用ObjectMapper反序列化{"sUserName":"admin","password":"x","companyId":1}→ DTOgetSUserName()=="admin"(验证@JsonProperty("sUserName")生效)。 -
backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.java(ObjectMapper序列化): -
::serializesContractKeys—— 填满字段序列化后 JSON 含键id/sCompanyName/sVersion,不含SCompanyName等大驼峰键。
-
- 实现:
modules/usr/dto/LoginDTO.java(按「LoginDTO 形状」字段 +@NotBlank/@NotNull/@Size+@JsonProperty("sUserName")+ getter/setter);modules/usr/vo/CompanyOptionVO.java(按「CompanyOptionVO 形状」字段 +@JsonProperty锁匈牙利前缀键 + getter/setter,implements Serializable)。 - 验证:子会话跑两测试 PASS。
- commit:
feat(usr): 登录入参 LoginDTO 与公司下拉项 CompanyOptionVO REQ-USR-004
T3 — LoginVO(含嵌套 user)输出 + UsrCompany 实体 + UsrCompanyMapper
- 测试:
-
backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.java(ObjectMapper序列化): -
::serializesTokenAndNestedUserNoPassword—— 构造LoginVO(token="t.t.t"、user{ id=1, sUserName="admin", sUserType="超级管理员", sLanguage="中文" }),序列化后 JSON 含键token与嵌套对象user,user内含id/sUserName/sUserType/sLanguage;不含sPassword/password/SUserName(验证嵌套结构与@JsonProperty锁键、无密码字段)。 -
backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapperTest.java(@SpringBootTest+@ActiveProfiles("test")+@Transactional(回滚避免污染库)连测试库;@Autowired UsrCompanyMapper;测试内插 1 条usr_companyfixture,名前缀如IT4_CO_): -
::selectByIdReturnsCompany—— 插入 fixture 后selectById(生成 id)取回,断言getSCompanyName()/getSVersion()等于 fixture 值、getIIncrement()非 null。 -
::selectListReturnsAll——selectList(null)含刚插入的 fixture(total≥1,结果含该公司)。
-
- 实现:
-
modules/usr/vo/LoginVO.java(按「LoginVO 形状」:token+ 嵌套user;嵌套用静态内部类UserInfo或独立 VO,字段加@JsonProperty锁键,全字段 getter/setter,implements Serializable,不含密码)。 -
modules/usr/entity/UsrCompany.java(@TableName("usr_company"),继承BaseEntity复用标准列,业务列sCompanyName/sVersion加@TableField+ 匈牙利前缀 getter/setter)。 -
modules/usr/mapper/UsrCompanyMapper.java(@Mapper public interface UsrCompanyMapper extends BaseMapper<UsrCompany>,无自定义方法)。
-
- 验证:子会话跑两测试 PASS(Mapper 测试连库确认实体 ↔
usr_company列映射正确、BaseMapper可用)。 - commit:
feat(usr): 登录输出 LoginVO 与 UsrCompany 实体 Mapper REQ-USR-004
T4 — UsrAuthService 认证核心(查用户 → BCrypt → 判禁用 → companyId 校验 → 签发 → 更新登录时间 → 限流)
- 测试:
backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java(Mockito:@Mock UsrUserMapper/UsrCompanyMapper/PasswordEncoder/JwtUtil;用@InjectMocks或构造器注入;限流阈值通过构造器 / 反射注入maxFail=3、lockSeconds=300便于断言;selectOne桩返回UsrUserfixture):-
::successReturnsTokenAndUpdatesLoginTime—— 用户存在、iIsVoid=0、passwordEncoder.matches桩 true、companyId桩存在、jwtUtil.generateToken桩返回"jwt.token"→ 返回的LoginVO.token=="jwt.token"、user.id/sUserName/sUserType/sLanguage来自 fixture;验证usrUserMapper.updateById被调用一次且入参实体tLastLoginDate非 null(且仅 set 主键 +tLastLoginDate,未覆写其他列)。 -
::userNotFoundThrows40101——selectOne桩返回 null → 抛BusinessException(UNAUTHORIZED),不调passwordEncoder.matches、不调updateById、不签发 token。 -
::wrongPasswordThrows40101—— 用户存在但matches桩 false → 抛BusinessException(UNAUTHORIZED),不调updateById、不签发 token。 -
::notFoundAndWrongPasswordSameCodeAndMessage—— 断言「用户不存在」与「密码错误」两路径抛出的BusinessException的getResultCode()相同(均UNAUTHORIZED)且getMessage()逐字相同(防枚举,spec § 3 规则 2/3)。 -
::disabledUserThrows40302AfterPasswordOk—— 用户存在、matchestrue、但iIsVoid=1→ 抛BusinessException(ACCOUNT_DISABLED)(验证先验密码再判禁用,spec § 8 D3);不签发 token、不更新登录时间。 -
::illegalCompanyIdThrows40001—— 用户存在、matchestrue、iIsVoid=0,但usrCompanyMapper.selectById(companyId)桩返回 null → 抛BusinessException(PARAM_INVALID);不签发 token、不更新登录时间。 -
::rateLimitAfterMaxFailThrows42901—— 连续触发maxFail次密码错误后,下一次login(即使matches桩 true)在锁定窗内抛BusinessException(LOGIN_RATE_LIMITED),且该次不调selectOne之后的认证逻辑 / 不签发 token(锁定判定在最前)。 -
::successResetsFailCounter—— 先失败若干次(< maxFail)再一次成功登录后,失败计数清零:随后再连续失败到「成功前的次数 + 1」不应触发42901(验证成功清零,spec 验收 9)。 -
::listCompaniesMapsAllRows——usrCompanyMapper.selectList(...)桩返回 2 条UsrCompany→listCompanies()返回 2 条CompanyOptionVO,字段id/sCompanyName/sVersion来自实体。
-
- 实现:
-
modules/usr/service/UsrAuthService.java:接口声明LoginVO login(LoginDTO dto)+List<CompanyOptionVO> listCompanies()。 -
modules/usr/service/impl/UsrAuthServiceImpl.java(@Service,构造器注入UsrUserMapper/UsrCompanyMapper/PasswordEncoder/JwtUtil;@Value注入auth.login.max-fail:5/auth.login.lock-seconds:300;进程内ConcurrentHashMap<String, 失败计数+锁定到期>计数器): -
login标@Transactional(rollbackFor = Exception.class),严格按「认证判定顺序」执行:①限流窗判定(命中 →42901)→ ②按sUserName(trim)selectOne查用户,null → 记一次失败 + 抛UNAUTHORIZED→ ③passwordEncoder.matches(dto.password, user.sPassword)false → 记一次失败 + 抛UNAUTHORIZED(与②同码同 message)→ ④iIsVoid==1→ 抛ACCOUNT_DISABLED(禁用不计入失败计数,避免锁死禁用账号;可记 decisions)→ ⑤usrCompanyMapper.selectById(companyId)null → 抛PARAM_INVALID(「版本不存在」类不含枚举信息的中性提示)→ ⑥jwtUtil.generateToken(user.sUserName, user.sUserType)签发;构造仅含主键 +tLastLoginDate=LocalDateTime.now()的UsrUser调updateById;清零该用户失败计数;装配LoginVO(token +user{id,sUserName,sUserType,sLanguage})返回。密码明文绝不进日志 / 异常 message。 -
listCompanies标@Transactional(readOnly = true):selectList(null)→ 映射为List<CompanyOptionVO>。
-
- 验证:子会话跑上述用例 PASS。
- commit:
feat(usr): 登录认证 Service 与公司列表 REQ-USR-004
T5 — UsrAuthController 端点 + SecurityConfig 放行 /api/usr/companies
- 测试:
-
backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java(MockMvcstandaloneSetup+ 真实GlobalExceptionHandler+@Mock UsrAuthService): -
::loginReturnsCodeZeroWithTokenAndUser——usrAuthService.login桩返回填好的LoginVO,POST /api/usr/login合法 body → HTTP 200、code==0、data.token存在、data.user.id/data.user.sUserName/data.user.sUserType/data.user.sLanguage存在;响应体不含sPassword/password。 -
::loginMissingFieldReturns40001—— body 缺companyId(@NotNull失败)→code==40001,usrAuthService.login不被调用。 -
::loginAuthFailReturns40101—— Service 桩抛BusinessException(UNAUTHORIZED)→code==40101(Controller 不吞业务异常、全局处理器转码)。 -
::loginDisabledReturns40302—— Service 桩抛BusinessException(ACCOUNT_DISABLED)→code==40302。 -
::loginRateLimitedReturns42901—— Service 桩抛BusinessException(LOGIN_RATE_LIMITED)→code==42901。 -
::companiesReturnsCodeZeroList——usrAuthService.listCompanies桩返回 1 条CompanyOptionVO,GET /api/usr/companies→code==0、data[0].id/data[0].sCompanyName存在。 -
backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java(续既有类或新增用例,验证放行清单含/api/usr/companies): -
::companiesEndpointPermitted—— 校验GET /api/usr/companies被放行(与既有/api/usr/login放行断言同口径;既有SecurityConfigTest已有放行断言模式,按其风格补一条)。
-
- 实现:
-
modules/usr/controller/UsrAuthController.java(@RestController@RequestMapping("/api/usr"),构造器注入UsrAuthService):@PostMapping("/login") login(@Valid @RequestBody LoginDTO dto)返回Result.success(usrAuthService.login(dto));@GetMapping("/companies") listCompanies()返回Result.success(usrAuthService.listCompanies())。仅校验 + 委派,无业务逻辑、不直接调 Mapper、无管理员前置(登录 / 公司列表为放行端点)。 -
common/config/SecurityConfig.java:在requestMatchers(...permitAll())放行清单追加"/api/usr/companies"("/api/usr/login"已存在,不删除既有放行项)。
-
- 验证:子会话跑
UsrAuthControllerTest+SecurityConfigTestPASS。 - commit:
feat(usr): 登录与公司列表 Controller 及安全放行 REQ-USR-004
T6 — 限流配置项写入 application.yml(+ test profile)
- 测试:
backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java(@SpringBootTest+@ActiveProfiles("test"),注入配置值断言):-
::loginConfigDefaultsBound—— 用@Value("${auth.login.max-fail}")/@Value("${auth.login.lock-seconds}")(或Environment)断言能解析出整数(默认5/300,验证application.yml已声明键、Service@Value注入不会因缺键启动失败)。
-
- 实现:
backend/src/main/resources/application.yml新增配置段(值引config-vars.yaml;config-vars 无登录限流键,采用 spec § 8 D7 默认值,并按既有jwt.*写法用 env 占位允许覆盖):yaml auth: login: max-fail: ${AUTH_LOGIN_MAX_FAIL:5} lock-seconds: ${AUTH_LOGIN_LOCK_SECONDS:300}test profile(application-test.yml)可不重复声明(继承主 profile 默认即可);若AuthLoginConfigIT在 test profile 下取不到键,则在application-test.yml补同名段(记 decisions)。 - 验证:子会话跑
AuthLoginConfigITPASS(确认应用在 test profile 能启动且配置键可解析)。 - commit:
chore(usr): 登录限流阈值配置项 REQ-USR-004
T7 — 端到端验收回归(按 spec § 7 验收标准 1-12 收口)
- 测试:
backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java(@SpringBootTest+@AutoConfigureMockMvc+@ActiveProfiles("test"),连测试库 Flyway 已 apply V1;真实JwtUtil/BCryptPasswordEncoder/ 安全链;@AfterEach按前缀清理 fixture,用户名前缀如it_login_、公司名前缀如IT_LOGIN_CO_;登录用户的sPassword用真实passwordEncoder.encode("666666")写入)覆盖 spec § 7:-
::ac1LoginSuccess—— 预置iIsVoid=0用户(密码哈希666666)+ 1 条公司 fixture,POST /api/usr/login(正确用户名 + 密码 + 该companyId)→code=0、data.token非空、data.user.id/sUserName/sUserType/sLanguage正确;响应体不含sPassword/password/明文666666。 -
::ac2TokenAcceptedByProtectedApi—— 用 ac1 返回的token作Authorization: Bearer <token>调GET /api/usr/users?pageNum=1&pageSize=10(REQ-USR-003 受保护接口)→ 非 401(通过 JWT 过滤器,code=0或至少不被安全链拒)。 -
::ac3LoginUpdatesLastLoginDate—— 记录登录前该用户tLastLoginDate(null 或旧值),登录成功后selectById查该用户,断言tLastLoginDate已更新为非 null 且 ≥ 登录前(由 null 变非 null)。 -
::ac4WrongPassword40101—— 正确用户名 + 错误密码 →code=40101,响应体不含token、不含「密码错误」字样。 -
::ac5UserNotFound40101SameAsWrongPassword—— 不存在用户名 →code=40101,且其message与 ac4「密码错误」响应的message逐字相同(无法据响应区分账号是否存在)。 -
::ac6DisabledUser40302—— 预置iIsVoid=1用户(密码哈希正确),正确凭据登录 →code=40302、不返回token;登录后查该用户tLastLoginDate未更新。 -
::ac7MissingParam40001—— 分别缺sUserName/ 缺password/ 缺companyId→ 各code=40001,不进入认证(不返回 token)。 -
::ac8IllegalCompanyId40001—— 正确凭据但companyId取一个usr_company不存在的值(如Integer.MAX_VALUE)→code=40001。 -
::ac9RateLimitAfter5Fails42901—— 对同一账号连续 5 次密码错误(每次code=40101),第 6 次即使密码正确也code=42901;为隔离限流计数器状态,本用例用独立专属用户名(前缀it_login_rl_),并验证一次成功登录(或等待窗口)后计数清零、恢复正常code=0(窗口等待不可行时,断言「成功登录后计数清零」分支:先失败 < 5 次→成功→再失败仍不触发 42901)。 -
::ac10CompaniesListNoToken—— 不带 token 调GET /api/usr/companies→ HTTP 200、code=0、data含公司 fixture 项{id, sCompanyName, sVersion}(该端点放行)。 -
::ac11PasswordNeverLeaks—— 上述任意成功 / 失败响应体均不含sPassword/password字段、不含明文密码666666。 -
::ac12JwtHasExpiry—— 解码 ac1 返回的token(用jwtUtil或直接解析 claims)断言含 exp(有效期有限);可选:用一个已过期 token(如用极短 expire 或手工构造)调受保护接口 → 401(若构造过期 token 成本高,至少断言签发 token 解析出的过期时间晚于签发时间,记 decisions)。
-
- 实现:仅在前序 task 暴露缺口时做最小修补(如限流计数器跨用例污染需按用户名隔离、
updateById仅更新tLastLoginDate的语义、统一文案逐字一致),不引入新公共契约、不新增 migration。 - 验证:子会话跑
UsrLoginITPASS(连库);随后全量mvn -q -B test全绿、mvn -q -B checkstyle:check通过。 - commit:
test(usr): 登录端到端验收回归 REQ-USR-004
自审
占位符扫描
- 全文无
【人工填写】/TBD/TODO/ 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记(sLanguage/sVersion/usr_permission粒度)不在本登录 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}、sVersion直透展示)继续,不阻塞。
Spec coverage(spec 每节 → task 映射)
- § 1 Goal(
POST /api/usr/login主端点 +GET /api/usr/companies配套只读端点)→ T5(两端点)+ T4(认证 + 公司列表逻辑)+ 全部 task。 - § 2.1 输入
LoginDTO(字段 / 校验 / Auth 放行)→ T2(DTO 校验 + JSON 键)+ T5(端点放行 +@Valid)。 - § 2.2 输出
Result<LoginVO>(含嵌套user,不含sPassword)→ T3(LoginVO嵌套 + 无密码)+ T4(装配)+ T5(Controller 组装)+ T7 ac1/ac11。 - § 2.3
GET /api/usr/companies输入 / 输出CompanyOptionVO→ T2(CompanyOptionVO)+ T3(UsrCompany实体 / Mapper)+ T4(listCompanies)+ T5(端点)+ T7 ac10。 - § 3 规则 1 认证主流程(顺序判定)→ T4(认证顺序 + 各错误码)+ 合同级常量「认证判定顺序」+ T7 ac1/ac4/ac6/ac7/ac8。
- § 3 规则 2/3 防枚举 + 统一失败提示(用户不存在 = 密码错误,同码同文案)→ T4
::notFoundAndWrongPasswordSameCodeAndMessage+ T7 ac5(逐字相同)。 - § 3 规则 4 JWT 签发(无状态 + exp + claim 含用户标识 / 类型)→ T4(
jwtUtil.generateToken)+ T7 ac2/ac12 + 合同级常量「JWT」。 - § 3 规则 5 / § 8 D4 禁用 / 删除统一
iIsVoid=1→40302→ T4::disabledUserThrows40302AfterPasswordOk+ T7 ac6。 - § 3 规则 6 / § 8 D2
companyId存在性校验、不参与认证绑定、非法 →40001→ T4::illegalCompanyIdThrows40001+ T7 ac8。 - § 3 规则 7 / § 8 D6 更新
tLastLoginDate(同事务,失败回滚)→ T4(@Transactional+updateById仅改该列)+ T7 ac3。 - § 3 规则 8 / § 8 D7 连续失败限流
42901(进程内计数 / 阈值配置)→ T1(错误码)+ T4(计数器 + 锁定判定 + 成功清零)+ T6(配置项)+ T7 ac9。 - § 3 规则 9 / 验收 11 密码与敏感字段不返回 → T2/T3(VO 无密码)+ T4(不外泄)+ T7 ac11。
- § 3 规则 10
GET /api/usr/companies只读无副作用 → T4(@Transactional(readOnly=true)+ 仅selectList)+ T7 ac10。 - § 4 约束(分层
UsrAuthController→UsrAuthService→Mapper / 包路径 / 命名login/listCompanies/ 统一响应 / 异常 / 事务 / 认证安全 / 数据访问 / 配置 / schema 不改)→ T1-T6 分层落位;安全放行 T5;配置 T6;schema 复用 V1(Tech Stack)。 - § 5 Schema 引用(读 + 写
usr_user、读usr_company,不 SELECT 密码外泄)→ T3(UsrCompany实体 / Mapper)+ T4(selectOne/updateById/selectById/selectList)。 - § 6 错误码(
0/40001/40101/40302/42901/401)→ T1(42901新增)+ 复用既有ResultCode(40101/40302/40001)+ T4(抛码)+ T5(全局处理器转码)+ T7(401 安全链)。 - § 7 验收标准 1-12 → T7 ac1-ac12 逐条覆盖。
- § 8 decisions(D1-D11)→ D1(补
GET /api/usr/companies)T2/T3/T4/T5;D2(companyId 存在性 /40001)T4;D3(先验密码再判禁用)T4/合同级常量「认证判定顺序」;D4(已删除≡iIsVoid=1)T4;D5(公司列表不分页)T4(selectList(null));D6(更新登录时间同事务回滚)T4;D7(进程内限流 + 阈值配置 +42901)T1/T4/T6;D8(新建UsrAuthController)T5;D9(新建UsrCompany/UsrCompanyMapper)T3;D10(JWT 过期时间默认)见下「关于 JWT 过期配置」决策;D11(不新增 migration)Tech Stack 已体现。
类型一致性
-
UsrAuthService#login(LoginDTO):LoginVO/#listCompanies():List<CompanyOptionVO>在 T4 定义,T5(Controller 调用)/ T7(IT)一致引用。 -
UsrAuthController#login(@Valid @RequestBody LoginDTO):Result<LoginVO>/#listCompanies():Result<List<CompanyOptionVO>>,与 docs/05 契约 + spec § 2 一致。 -
UsrCompanyMapper extends BaseMapper<UsrCompany>在 T3 定义,T4 调用(selectById/selectList)一致。 -
LoginDTO(sUserName/password/companyId+@NotBlank/@NotNull/@Size+@JsonProperty("sUserName"))在 T2 锁定,T4/T5/T7 一致。 -
LoginVO(token+ 嵌套user{id,sUserName,sUserType,sLanguage})在 T3 锁定,T4/T5/T7 一致;严格不含sPassword/租户列。 -
CompanyOptionVO(id/sCompanyName/sVersion)在 T2 锁定,T3/T4/T5/T7 一致。 -
UsrCompany实体在 T3 锁定(@TableName("usr_company")+sCompanyName/sVersion+ 继承BaseEntity标准列),与 V1usr_company列一致。 - 错误码字面量
0/40001/40101/40302/42901与 docs/05、spec § 6 一致:SUCCESS/PARAM_INVALID/UNAUTHORIZED/ACCOUNT_DISABLED复用既有,仅新增LOGIN_RATE_LIMITED=42901(T1)。 - REST 路径
POST /api/usr/login、GET /api/usr/companies与 docs/05、spec 一致;二者均放行(T5SecurityConfig)。 - 复用既有
JwtUtil.generateToken(userName, userType)(claim 含sUserName/sUserType+ exp)、PasswordEncoder、UsrUserMapper、BusinessException、Result、GlobalExceptionHandler,签名与既有代码一致;实体 / VO getter 沿用匈牙利前缀 +@JsonProperty锁键风格(与UserVO/CreateUserDTO同)。
关于 JWT 过期配置(spec § 8 D10 的实现期落地决策)
- spec § 8 D10 建议新增
jwt.expiration(默认 7200 秒)。但既有代码已存在JwtUtil+application.ymljwt.expire-millis(默认 43200000ms = 12 小时,REQ-USR-001 T3 锁定)。为遵循 DRY 与「复用既有签发组件」,本计划复用既有jwt.expire-millis,不新增jwt.expiration配置键、不改JwtUtil——既满足 spec「JWT 必须含过期时间」的硬要求(既有实现已.expiration(...)),又避免双过期配置漂移。该决策记入返回decisions[](覆盖 spec § 8 D10 的具体键名 / 默认值)。验收 ac12 只断言「含 exp 且有限有效期」,不绑定具体秒数,故不受影响。