Files
Webflux-Payments/src/main/java/com/test/payment/service/TokenCacheService.java
2026-07-02 16:45:48 +03:00

112 lines
4.8 KiB
Java

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<String> getToken(String provider, Supplier<Mono<FetchedToken>> 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<Void> 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<String> 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<String> 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<String> fetchAndStore(String provider, Supplier<Mono<FetchedToken>> 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<Void> 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);
}
}