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)。 * *

并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数; * 禁止以 {@code findById -> set -> save} 作为并发保证。

*/ @Repository public interface AuthTokenRepository extends JpaRepository { List findByUserIdAndRevokedFalse(Long userId); Optional findByTokenHash(String tokenHash); Optional findByRefreshTokenHash(String refreshTokenHash); List findByExpiredAtBefore(LocalDateTime expiredAt); // ── S02 会话查询 ──────────────────────────────────────────── /** * 按摘要取候选 Refresh 记录(同摘要理论上唯一,取最新一条以防历史重复)。 */ Optional findTopByTokenHashOrderByIdDesc(String tokenHash); /** * 会话是否活动:同一 user + session 下存在未消费、未撤销且未过期的 Refresh 记录。 * *

旧数据 {@code sessionId IS NULL} 永远不会命中。

*/ @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 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); }