package com.test.payment.service; import com.test.payment.models.ProviderToken; import com.test.payment.repository.ProviderTokenRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import java.time.Duration; import java.time.LocalDateTime; import java.util.function.Supplier; /** * Tiered OAuth token cache shared by all providers: Redis first, then the database, * then a live fetch (persisted to both tiers on success). Redis being down never * fails a request — reads/writes time out and degrade to the next tier. */ @Service @RequiredArgsConstructor @Slf4j public class TokenCacheService { private static final Duration REDIS_TIMEOUT = Duration.ofMillis(800); private final ProviderTokenRepository tokenRepository; private final ReactiveStringRedisTemplate redisTemplate; private final Environment environment; public record FetchedToken(String accessToken, long expiresInSeconds) { } public Mono getToken(String provider, Supplier> fetcher) { return fromRedis(provider) .switchIfEmpty(Mono.defer(() -> fromDatabase(provider))) .switchIfEmpty(Mono.defer(() -> fetchAndStore(provider, fetcher))); } /** * Drops the cached token from Redis and the database (used on 401 from the provider). */ public Mono evictToken(String provider) { return redisTemplate.opsForValue().delete(redisKey(provider)) .timeout(REDIS_TIMEOUT) .onErrorResume(e -> { log.warn("[{}] Redis unavailable while evicting token: {}", provider, e.toString()); return Mono.just(false); }) .then(tokenRepository.deleteByProvider(provider)); } private Mono fromRedis(String provider) { return redisTemplate.opsForValue().get(redisKey(provider)) .timeout(REDIS_TIMEOUT) .doOnNext(t -> log.debug("[{}] token served from Redis", provider)) .onErrorResume(e -> { log.warn("[{}] Redis unavailable for token read ({}) — falling back to database", provider, e.toString()); return Mono.empty(); }); } private Mono fromDatabase(String provider) { int buffer = expiryBufferSeconds(); return tokenRepository.findFirstByProviderAndExpiresAtAfterOrderByIdDesc(provider, LocalDateTime.now().plusSeconds(buffer)) .flatMap(token -> { long ttl = Duration.between(LocalDateTime.now(), token.getExpiresAt()).getSeconds() - buffer; log.debug("[{}] token served from database", provider); return cacheInRedis(provider, token.getAccessToken(), ttl).thenReturn(token.getAccessToken()); }); } private Mono fetchAndStore(String provider, Supplier> fetcher) { return fetcher.get() .flatMap(fetched -> { ProviderToken token = ProviderToken.builder() .provider(provider) .accessToken(fetched.accessToken()) .expiresAt(LocalDateTime.now().plusSeconds(fetched.expiresInSeconds())) .createdAt(LocalDateTime.now()) .build(); log.info("[{}] fetched new access token (expires in {}s)", provider, fetched.expiresInSeconds()); return tokenRepository.save(token) .then(cacheInRedis(provider, fetched.accessToken(), fetched.expiresInSeconds() - expiryBufferSeconds())) .thenReturn(fetched.accessToken()); }); } private Mono cacheInRedis(String provider, String token, long ttlSeconds) { if (ttlSeconds <= 0) { return Mono.empty(); } return redisTemplate.opsForValue().set(redisKey(provider), token, Duration.ofSeconds(ttlSeconds)) .timeout(REDIS_TIMEOUT) .doOnNext(ok -> log.debug("[{}] token cached in Redis for {}s", provider, ttlSeconds)) .onErrorResume(e -> { log.warn("[{}] Redis unavailable for token write ({}) — token persisted in database only", provider, e.toString()); return Mono.just(false); }) .then(); } private String redisKey(String provider) { return provider.toLowerCase() + ":access_token"; } private int expiryBufferSeconds() { return environment.getProperty("payments.token-expiry-buffer-seconds", Integer.class, 60); } }