feat : 已参考注释说明的方案:

1、登录请求 — 失败时 return,由 mutation 状态驱动错误 UI
2.、ocalStorage 存储 — 独立 try/catch,失败仅 console.warn,不影响后续流程
3、页面导航 — 始终在登录成功后执行,不受存储异常干扰
4、把整个读取过程都纳入异常处理,避免页面初始化阶段被存储异常中断。
5、将 save(detachedEntity) 改为定向 JPQL update,只更新 failedAttempts 和 lockedUntil 两个字段,彻底消除并发覆盖风险
This commit is contained in:
翟二远 2026-04-16 09:12:03 +08:00
parent a3def057fa
commit fb3de15f85
3 changed files with 36 additions and 16 deletions

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.auth.local;
import jakarta.annotation.Resource;
import jakarta.annotation.Nonnull;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@ -29,14 +30,20 @@ public class LocalAuthFailedService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleFailedLogin(LocalCredential credential) {
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void handleFailedLogin(@Nonnull Long credentialId) {
LocalCredential credential = credentialRepository.findById(credentialId)
.orElseThrow(() -> new EntityNotFoundException("Invalid credential id"));
int failedAttempts = credential.getFailedAttempts() + 1;
credential.setFailedAttempts(failedAttempts);
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
credential.setLockedUntil(currentTime().plus(LOCK_DURATION));
Instant lockedUntil = credential.getLockedUntil();
if (failedAttempts >= MAX_FAILED_ATTEMPTS && lockedUntil == null) {
lockedUntil = currentTime().plus(LOCK_DURATION);
}
credentialRepository.save(credential);
credentialRepository.updateFailedAttemptsAndLockedUntil(credentialId, failedAttempts, lockedUntil);
}
private Instant currentTime() {

View file

@ -1,7 +1,10 @@
package com.iflytek.skillhub.auth.local;
import java.time.Instant;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
/**
@ -15,4 +18,8 @@ public interface LocalCredentialRepository extends JpaRepository<LocalCredential
Optional<LocalCredential> findByUserId(String userId);
boolean existsByUsernameIgnoreCase(String username);
@Modifying
@Query("UPDATE LocalCredential c SET c.failedAttempts = :failedAttempts, c.lockedUntil = :lockedUntil WHERE c.id = :id")
int updateFailedAttemptsAndLockedUntil(Long id, int failedAttempts, Instant lockedUntil);
}

View file

@ -35,17 +35,17 @@ export function LoginPage() {
// Load saved username from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(REMEMBER_ME_KEY)
if (saved) {
try {
try {
const saved = localStorage.getItem(REMEMBER_ME_KEY)
if (saved) {
const { username: savedUsername } = JSON.parse(saved)
if (savedUsername) {
setUsername(savedUsername)
setRememberMe(true)
}
} catch {
// Invalid data, ignore
}
} catch (e) {
console.warn('Failed to load remembered username:', e)
}
}, [])
@ -76,7 +76,12 @@ export function LoginPage() {
setFieldErrors({})
try {
await loginMutation.mutateAsync({ username: trimmedUsername, password })
// Save username to localStorage if remember me is checked
} catch {
// mutation state drives the error UI
return
}
try {
if (rememberMe) {
localStorage.setItem(REMEMBER_ME_KEY, JSON.stringify({
username: trimmedUsername
@ -84,10 +89,11 @@ export function LoginPage() {
} else {
localStorage.removeItem(REMEMBER_ME_KEY)
}
await navigate({ to: returnTo })
} catch {
// mutation state drives the error UI
} catch (e) {
console.warn('Failed to save remember-me preference:', e)
}
await navigate({ to: returnTo })
}
return (