2026-06-01-REQ-USR-004.md 37.9 KB

REQ-USR-004 登录用户 — 任务级 TDD 计划(后端)

阶段:后端(backend)。作用域:backend/**(controller / service / service.impl / mapper / DTO / VO / entity / config / 公共响应 / 安全 / REST 契约实现)。禁止frontend/**。 上游 SSoT:spec docs/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 / PasswordEncoder Bean);只读 usr_user / usr_company + UPDATE usr_user.tLastLoginDate(DML 非 DDL),不新增 migration(spec § 4 / § 8 D11,二表已在 V1__initial_schema.sql 建好且结构与 docs/03 一致)。


Goal(目标)

实现登录认证主端点 POST /api/usr/login(放行,无需 token):接收 LoginDTOsUserName / password / companyId,三者必填),按「基础参数校验 → 查用户 → BCrypt 比对密码 → 判禁用 → companyId 存在性校验 → 签发 JWT + 更新 tLastLoginDate」流程认证,成功返回 Result<LoginVO>code=0,含 tokenuser{ id, sUserName, sUserType, sLanguage }绝不含 sPassword)。配套补齐只读端点 GET /api/usr/companies(放行,无参,返回 Result<List<CompanyOptionVO>> 供登录页「版本」下拉)。

防账号枚举:用户不存在与密码错误返回完全相同40101(统一文案);禁用用户 → 40302;参数缺失或 companyIdusr_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(仅 set iIncrement + tLastLoginDate,依赖 MP「null 字段不参与 SET」语义,不触碰其他列);按 companyId 校验存在用 UsrCompanyMapper.selectById;公司列表用 UsrCompanyMapper.selectList(null)。全部参数化(#{} / MP 预编译)防注入。不 SELECT sPassword 到响应(实体查出含 sPassword 供比对,但 UsrUser.sPassword 已标 @JsonIgnoreLoginVO 不含该字段,绝不外泄)。简单 CRUD,无需自定义 XML。
  • 事务login 含写(UPDATE tLastLoginDate),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。密码用既有 BCryptPasswordEncoder Bean 比对,禁止明文。JWT 用既有 JwtUtil.generateToken(userName, userType) 签发(含 exp claim),密钥来自 application.yml jwt.secret(引 config-vars secrets.jwt_secret),不硬编码。
  • 限流:进程内(内存)按 sUserName 计数的轻量限流(spec § 3 规则 8 / § 8 D7),不依赖 Redis;阈值 / 窗口读 application.yml auth.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(启动 / 测试启动自动 apply sql/migrations/本 REQ 不新增 migration,复用 V1__initial_schema.sql,spec § 4 / § 8 D11)。
  • Spring Security + JWT(既有 JwtUtil / JwtAuthenticationFilter / SecurityUtil / SecurityConfig / PasswordEncoder Bean);POST /api/usr/loginGET /api/usr/companies 放行。
  • 根包 com.xly.erp;端口 / DB 凭据 / JWT 密钥 / 限流阈值只读 config-vars.yaml / application.yml,不硬编码。
  • 命令(docs/04 § 零):build mvn -q -B -DskipTests package;lint mvn -q -B checkstyle:check;unit mvn -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 — 成功。loginLoginVOcompaniesList<CompanyOptionVO>
    • PARAM_INVALID=40001 — 参数校验失败(缺 sUserName / password / companyId,或 companyIdusr_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)→ ②按 sUserNameusr_user,未查到 → 40101 → ③先 BCrypt 比对密码,不匹配 → 40101 → ④再判 iIsVoid=140302(先验密码再返禁用码,最小化枚举面,spec § 8 D3)→ ⑤companyId 存在性校验(不存在 → 40001)→ ⑥签发 JWT + 更新 tLastLoginDate + 清零失败计数 + 返回 LoginVO。限流判定(42901)置于流程最前(查用户前即查锁定窗,命中锁定窗直接 42901,spec 验收 9:锁定窗内即使密码正确也 42901)。
  • JWT:复用既有 JwtUtil.generateToken(String userName, String userType),subject = sUserName、claim 含 sUserNameJwtUtil.CLAIM_USER_NAME)+ sUserTypeJwtUtil.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)的 getter getSUserName 会被 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 是否已含 iIncrement getter(getIIncrement——UsrUsergetIIncrement() 取主键,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() == 42901getMessage() 非空(如「请求过于频繁,请稍后重试」)。
    • ::existingLoginCodesPresent —— 断言既有 UNAUTHORIZED.getCode()==40101ACCOUNT_DISABLED.getCode()==40302PARAM_INVALID.getCode()==40001(确认复用、不重复定义)。
  • 实现common/response/ResultCode.java 新增枚举常量 LOGIN_RATE_LIMITED(42901, "请求过于频繁,请稍后重试")不改动 40101/40302/40001 既有常量。
  • 验证:子会话跑 ResultCodeLoginTest PASS。
  • commitfeat(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=adminpassword=666666companyId=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} → DTO getSUserName()=="admin"(验证 @JsonProperty("sUserName") 生效)。
    • backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.javaObjectMapper 序列化):
    • ::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。
  • commitfeat(usr): 登录入参 LoginDTO 与公司下拉项 CompanyOptionVO REQ-USR-004

T3 — LoginVO(含嵌套 user)输出 + UsrCompany 实体 + UsrCompanyMapper

  • 测试
    • backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.javaObjectMapper 序列化):
    • ::serializesTokenAndNestedUserNoPassword —— 构造 LoginVOtoken="t.t.t"user{ id=1, sUserName="admin", sUserType="超级管理员", sLanguage="中文" }),序列化后 JSON 含键 token 与嵌套对象 useruser 内含 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_company fixture,名前缀如 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 可用)。
  • commitfeat(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=3lockSeconds=300 便于断言;selectOne 桩返回 UsrUser fixture):
    • ::successReturnsTokenAndUpdatesLoginTime —— 用户存在、iIsVoid=0passwordEncoder.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.matchesupdateById签发 token。
    • ::wrongPasswordThrows40101 —— 用户存在但 matches 桩 false → 抛 BusinessException(UNAUTHORIZED)updateById签发 token。
    • ::notFoundAndWrongPasswordSameCodeAndMessage —— 断言「用户不存在」与「密码错误」两路径抛出的 BusinessExceptiongetResultCode() 相同(均 UNAUTHORIZED)且 getMessage() 逐字相同(防枚举,spec § 3 规则 2/3)。
    • ::disabledUserThrows40302AfterPasswordOk —— 用户存在、matches true、但 iIsVoid=1 → 抛 BusinessException(ACCOUNT_DISABLED)(验证先验密码再判禁用,spec § 8 D3);签发 token、更新登录时间。
    • ::illegalCompanyIdThrows40001 —— 用户存在、matches true、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 条 UsrCompanylistCompanies() 返回 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()UsrUserupdateById;清零该用户失败计数;装配 LoginVO(token + user{id,sUserName,sUserType,sLanguage})返回。密码明文绝不进日志 / 异常 message。
    • listCompanies@Transactional(readOnly = true)selectList(null) → 映射为 List<CompanyOptionVO>
  • 验证:子会话跑上述用例 PASS。
  • commitfeat(usr): 登录认证 Service 与公司列表 REQ-USR-004

T5 — UsrAuthController 端点 + SecurityConfig 放行 /api/usr/companies

  • 测试
    • backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java(MockMvc standaloneSetup + 真实 GlobalExceptionHandler + @Mock UsrAuthService):
    • ::loginReturnsCodeZeroWithTokenAndUser —— usrAuthService.login 桩返回填好的 LoginVOPOST /api/usr/login 合法 body → HTTP 200、code==0data.token 存在、data.user.id/data.user.sUserName/data.user.sUserType/data.user.sLanguage 存在;响应体不含 sPassword/password
    • ::loginMissingFieldReturns40001 —— body 缺 companyId@NotNull 失败)→ code==40001usrAuthService.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 条 CompanyOptionVOGET /api/usr/companiescode==0data[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 + SecurityConfigTest PASS。
  • commitfeat(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)。
  • 验证:子会话跑 AuthLoginConfigIT PASS(确认应用在 test profile 能启动且配置键可解析)。
  • commitchore(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=0data.token 非空、data.user.id/sUserName/sUserType/sLanguage 正确;响应体不含 sPassword/password/明文 666666
    • ::ac2TokenAcceptedByProtectedApi —— 用 ac1 返回的 tokenAuthorization: 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 —— 不带 tokenGET /api/usr/companies → HTTP 200、code=0data 含公司 fixture 项 {id, sCompanyName, sVersion}(该端点放行)。
    • ::ac11PasswordNeverLeaks —— 上述任意成功 / 失败响应体均不含 sPassword/password 字段、不含明文密码 666666
    • ::ac12JwtHasExpiry —— 解码 ac1 返回的 token(用 jwtUtil 或直接解析 claims)断言含 exp(有效期有限);可选:用一个已过期 token(如用极短 expire 或手工构造)调受保护接口 → 401(若构造过期 token 成本高,至少断言签发 token 解析出的过期时间晚于签发时间,记 decisions)。
  • 实现:仅在前序 task 暴露缺口时做最小修补(如限流计数器跨用例污染需按用户名隔离、updateById 仅更新 tLastLoginDate 的语义、统一文案逐字一致),不引入新公共契约、不新增 migration。
  • 验证:子会话跑 UsrLoginIT PASS(连库);随后全量 mvn -q -B test 全绿、mvn -q -B checkstyle:check 通过。
  • committest(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=140302 → 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 约束(分层 UsrAuthControllerUsrAuthService→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 新增)+ 复用既有 ResultCode40101/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)一致。
  • LoginDTOsUserName/password/companyId + @NotBlank/@NotNull/@Size + @JsonProperty("sUserName"))在 T2 锁定,T4/T5/T7 一致。
  • LoginVOtoken + 嵌套 user{id,sUserName,sUserType,sLanguage})在 T3 锁定,T4/T5/T7 一致;严格不含 sPassword/租户列。
  • CompanyOptionVOid/sCompanyName/sVersion)在 T2 锁定,T3/T4/T5/T7 一致。
  • UsrCompany 实体在 T3 锁定(@TableName("usr_company") + sCompanyName/sVersion + 继承 BaseEntity 标准列),与 V1 usr_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/loginGET /api/usr/companies 与 docs/05、spec 一致;二者均放行(T5 SecurityConfig)。
  • 复用既有 JwtUtil.generateToken(userName, userType)(claim 含 sUserName/sUserType + exp)、PasswordEncoderUsrUserMapperBusinessExceptionResultGlobalExceptionHandler,签名与既有代码一致;实体 / VO getter 沿用匈牙利前缀 + @JsonProperty 锁键风格(与 UserVO/CreateUserDTO 同)。

关于 JWT 过期配置(spec § 8 D10 的实现期落地决策)

  • spec § 8 D10 建议新增 jwt.expiration(默认 7200 秒)。但既有代码已存在 JwtUtil + application.yml jwt.expire-millis(默认 43200000ms = 12 小时,REQ-USR-001 T3 锁定)。为遵循 DRY 与「复用既有签发组件」,本计划复用既有 jwt.expire-millis新增 jwt.expiration 配置键、JwtUtil——既满足 spec「JWT 必须含过期时间」的硬要求(既有实现已 .expiration(...)),又避免双过期配置漂移。该决策记入返回 decisions[](覆盖 spec § 8 D10 的具体键名 / 默认值)。验收 ac12 只断言「含 exp 且有限有效期」,不绑定具体秒数,故不受影响。