Skip to content

fix : 修复登陆失败,事务失效问题#317

Open
zhaieryuan wants to merge 1 commit into
iflytek:mainfrom
zhaieryuan:fix/user-login-failed-transactional
Open

fix : 修复登陆失败,事务失效问题#317
zhaieryuan wants to merge 1 commit into
iflytek:mainfrom
zhaieryuan:fix/user-login-failed-transactional

Conversation

@zhaieryuan

Copy link
Copy Markdown
Contributor

Summary

  • 将登录失败计数逻辑从 LocalAuthService.handleFailedLogin() 内联方法抽取到独立的
    LocalAuthFailedService,使用 @transactional(propagation = REQUIRES_NEW)
    在独立事务中执行,确保即使外层登录事务回滚,失败计数和账户锁定仍能正确持久化
  • 在 LocalCredentialRepository 中新增 updateFailedAttemptsAndLockedUntil JPQL 更新方法,避免通过 JPA
    dirty checking 依赖外层事务
  • 修复原有实现中 @transactional 因 Spring 代理机制对同类内部方法调用不生效的问题(self-invocation
    不经过代理,导致事务注解失效)

Validation

  • Backend tests passed
  • Frontend typecheck/build passed(本次无前端变更,无需验证)
  • OpenAPI SDK regenerated or checked when API contracts changed(本次无 API 契约变更)
  • Smoke test run when relevant

Commands run:

cd server && JDK_JAVA_OPTIONS="-XX:+EnableDynamicAgentLoading" ./mvnw test -pl skillhub-auth -am
-Dtest=LocalAuthServiceTest -Dsurefire.failIfNoSpecifiedTests=false

Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS

Risk

  • User-facing impact: 无功能变化,仅修复事务边界。登录失败计数和锁定行为与预期设计一致
  • Deployment or migration impact: 无数据库迁移,无配置变更,可直接部署
  • Rollback approach: 直接 revert 即可,无数据兼容性问题

Notes

  • Related issue: 登录失败时 handleFailedLogin 的 @transactional 因 Spring self-invocation
    不生效,导致失败计数未持久化
  • Follow-up work: 建议补充 LocalAuthFailedService 的独立单元测试
  • Docs or operator runbooks updated when behavior changed: 无需更新

涉及文件

┌───────────────────────────────────────────────────────────────┬────────────────────────────────┐
│ 文件 │ 变更 │
├───────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ server/skillhub-auth/src/main/java/.../LocalAuthFailedService │ 新增 — │
│ .java │ 独立事务处理失败登录计数 │
├───────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ │ 注入 │
│ server/skillhub-auth/src/main/java/.../LocalAuthService.java │ LocalAuthFailedService,login │
│ │ 方法中调用替代原内联方法 │
├───────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ server/skillhub-auth/src/main/java/.../LocalCredentialReposit │ 新增 updateFailedAttemptsAndLo │
│ ory.java │ ckedUntil JPQL 方法 │
├───────────────────────────────────────────────────────────────┼────────────────────────────────┤
│ │ 适配新架构:mock LocalAuthFail │
│ server/skillhub-auth/src/test/.../LocalAuthServiceTest.java │ edService,断言改为 verify │
│ │ 调用 │
└───────────────────────────────────────────────────────────────┴────────────────────────────────┘

@CLAassistant

CLAassistant commented Apr 16, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@XiaoSeS XiaoSeS left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢提交这个修复!整体思路是对的——用独立事务把失败计数和外层登录事务解耦,确实能解决计数不落库的问题。读完代码我整理了一些想法和疑问,希望对你有帮助 🙏

关于根因的小澄清

PR 描述里提到 "Spring self-invocation 导致 @Transactional 失效"——我对照了下原代码,handleFailedLogin(LocalCredential)private 方法且没有 @Transactional 注解,self-invocation 应该不是真实原因。

实际根因是:login() 标注了 @Transactional,当抛出 invalidCredentials()RuntimeException)时 Spring 默认会回滚整个事务,导致内部 credentialRepository.save(credential) 一并被丢弃。

这不影响修复方案的正确性(REQUIRES_NEW 解决的就是这个问题),但建议在 commit message 里调整一下表述,免得后续维护者被误导。

两个比较担心的点

1. 并发场景下计数可能丢失(lost-update)

新实现是「先查再算再写」三步:

LocalCredential credential = credentialRepository.findById(credentialId)...;
int failedAttempts = credential.getFailedAttempts() + 1;
credentialRepository.updateFailedAttemptsAndLockedUntil(credentialId, failedAttempts, lockedUntil);

如果同一账号短时间内被并发尝试(暴破场景就是典型),两个事务可能都读到 failedAttempts=N、都写 N+1,结果只增长了一次。考虑到这段代码的核心目的就是防暴破,这个 race condition 影响会比较直接。

是不是可以考虑用原子自增的 UPDATE 来规避?大致这个意思:

@Modifying
@Query("UPDATE LocalCredential c SET c.failedAttempts = c.failedAttempts + 1, " +
       "c.lockedUntil = CASE WHEN c.failedAttempts + 1 >= :threshold AND c.lockedUntil IS NULL " +
       "                     THEN :lockedUntil ELSE c.lockedUntil END " +
       "WHERE c.id = :id")
int incrementFailedAttempts(@Param(\"id\") Long id, @Param(\"threshold\") int threshold, @Param(\"lockedUntil\") Instant lockedUntil);

2. 锁定时长的判断逻辑变化

原代码:

if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
    credential.setLockedUntil(currentTime().plus(LOCK_DURATION));
}

新代码加了 lockedUntil == null 这个条件,意味着如果一个账号曾经被锁过(lockedUntil 字段非空,但已过期),后续再失败 5 次也不会续锁。

这是有意的语义调整吗?PR 描述写的是「无功能变化」,所以想跟你确认下。如果是无意改的,建议恢复原行为;如果是有意的,最好补个测试 + 在描述里说明一下。

测试相关

  • 原来的 login_afterMaxFailures_setsLockUsingInjectedClock 是真实断言锁定时间戳的,新版本改成 verify(...) 后,阈值判断和锁定时间计算 实际上没有单测覆盖了。建议在这个 PR 里就给 LocalAuthFailedService 补一组单测(while 在 review 期间补会比开 follow-up 顺一些),覆盖 failedAttempts == 4/== 5/> 5lockedUntil 各种状态的边界。
  • 这次修复的核心是「外层回滚 + 内层提交」的事务传播语义。建议补一个 @SpringBootTest 级别的集成测试触发一次失败登录,提交后从真实 DB 读 failed_attempts == 1——这样能真正验证修复有效,而不只是验证方法被调用。

几个小优化(不阻塞)

  • LocalAuthService 第 33-34 行的 MAX_FAILED_ATTEMPTSLOCK_DURATION 常量已经没人用了,建议清理掉,否则容易出现两边数值分叉。
  • LocalAuthFailedService 构造器后面有 3 个空行、方法体开头有 1 个空行,过 formatter 顺手清一下。
  • @Transactional(... rollbackFor = Exception.class):方法内只会抛 unchecked,加 rollbackFor 没有实际作用,可以去掉。
  • 类的 JavaDoc 建议加一句解释「为什么用 REQUIRES_NEW」——这是这个 service 存在的核心理由,写下来对后续维护者很有用。

总体来说方向是对的,主要建议是处理一下并发问题和确认锁定语义。期待更新 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants