轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

119 lines
4.7 KiB

package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* auth_tokens 数据访问(S02)。
*
* <p>并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数;
* 禁止以 {@code findById -> set -> save} 作为并发保证。</p>
*/
@Repository
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
List<AuthToken> findByUserIdAndRevokedFalse(Long userId);
Optional<AuthToken> findByTokenHash(String tokenHash);
Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash);
List<AuthToken> findByExpiredAtBefore(LocalDateTime expiredAt);
// ── S02 会话查询 ────────────────────────────────────────────
/**
* 按摘要取候选 Refresh 记录(同摘要理论上唯一,取最新一条以防历史重复)。
*/
Optional<AuthToken> findTopByTokenHashOrderByIdDesc(String tokenHash);
/**
* 会话是否活动:同一 user + session 下存在未消费、未撤销且未过期的 Refresh 记录。
*
* <p>旧数据 {@code sessionId IS NULL} 永远不会命中。</p>
*/
@Query("""
SELECT COUNT(t) FROM AuthToken t
WHERE t.userId = :userId
AND t.sessionId = :sessionId
AND t.revoked = false
AND t.consumedAt IS NULL
AND t.expiredAt > :capturedNow
""")
long countActiveSessionTokens(@Param("userId") Long userId,
@Param("sessionId") String sessionId,
@Param("capturedNow") LocalDateTime capturedNow);
List<AuthToken> findByUserIdAndSessionIdOrderByIdAsc(Long userId, String sessionId);
// ── S02 原子条件更新(返回值必须被严格检查)────────────────────
/**
* 原子消费一条 Refresh 记录。只有满足以下全部条件才会成功:
* id、expectedVersion、未撤销、未消费、未过期。
*
* @return 受影响行数;0 表示并发冲突、已被消费、已被撤销或已过期
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE AuthToken t
SET t.consumedAt = :consumedAt,
t.version = t.version + 1
WHERE t.id = :id
AND t.version = :expectedVersion
AND t.revoked = false
AND t.consumedAt IS NULL
AND t.expiredAt > :capturedNow
""")
int consumeIfCurrent(@Param("id") Long id,
@Param("expectedVersion") Long expectedVersion,
@Param("consumedAt") LocalDateTime consumedAt,
@Param("capturedNow") LocalDateTime capturedNow);
/**
* 撤销某用户某个会话下所有未撤销记录。
*
* @return 受影响行数
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE AuthToken t
SET t.revoked = true,
t.revokedAt = :revokedAt,
t.revocationReason = :reason,
t.version = t.version + 1
WHERE t.userId = :userId
AND t.sessionId = :sessionId
AND t.revoked = false
""")
int revokeSession(@Param("userId") Long userId,
@Param("sessionId") String sessionId,
@Param("revokedAt") LocalDateTime revokedAt,
@Param("reason") String reason);
/**
* 撤销某用户全部未撤销记录(账号注销完成、用户禁用等全局安全事件)。
*
* @return 受影响行数
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE AuthToken t
SET t.revoked = true,
t.revokedAt = :revokedAt,
t.revocationReason = :reason,
t.version = t.version + 1
WHERE t.userId = :userId
AND t.revoked = false
""")
int revokeAllForUser(@Param("userId") Long userId,
@Param("revokedAt") LocalDateTime revokedAt,
@Param("reason") String reason);
}