Added new endpoints to sync the list of games from steam if a user logged in.

This commit is contained in:
Andreas Dahm committed 2026-05-04 11:47:26 +02:00
1 parent ac97442312
commit 2f3507b57b
23 files changed
+742 -11

No files matched your search

+7
View File
@@ -17,6 +17,7 @@
<jwt.version>0.13.0</jwt.version>
<testcontainers.version>1.21.4</testcontainers.version>
<archunit.version>1.4.1</archunit.version>
<resilience4j.version>2.2.0</resilience4j.version>
</properties>
<dependencies>
<!-- Spring -->
@@ -45,6 +46,12 @@
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Resilience: retry + rate limiter for outbound Steam calls -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
@@ -0,0 +1,9 @@
package core_service.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}
@@ -5,6 +5,7 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.context.annotation.Profile;
@@ -18,14 +19,18 @@ import java.io.IOException;
import java.util.Collections;
/**
* Development-specific authentication filter that bypasses real JWT validation.
* It automatically injects a default user principal for every request when the 'dev' profile is active.
* Dev profile authentication filter.
* - If Authorization: Bearer ... is present and valid → builds principal from real JWT (so different users are isolated).
* - If no Authorization header → injects dummy principal so unauthenticated dev tools still work.
*/
@Component("jwtAuthenticationFilter")
@Profile("dev")
@RequiredArgsConstructor
@Slf4j
public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@@ -33,9 +38,13 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain
) throws ServletException, IOException {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// Default user principal for dev mode: Using the requested Steam ID 76561198000000001
UserPrincipal principal = new UserPrincipal("1", "dev@example.com", 76561198000000001L);
if (SecurityContextHolder.getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}
UserPrincipal principal = principalFromJwt(request).orElseGet(this::dummyPrincipal);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal,
null,
@@ -43,9 +52,36 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
log.trace("Dev mode: Injected default user principal with SteamID: {}", principal.steamId());
}
filterChain.doFilter(request, response);
}
private java.util.Optional<UserPrincipal> principalFromJwt(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return java.util.Optional.empty();
}
String jwt = authHeader.substring(7);
if (!jwtService.isTokenValid(jwt)) {
log.debug("Dev mode: invalid JWT, falling back to dummy principal");
return java.util.Optional.empty();
}
try {
String email = jwtService.extractEmail(jwt);
String userId = jwtService.extractUserId(jwt);
Long steamId = jwtService.extractSteamId(jwt);
if (email == null || userId == null) {
return java.util.Optional.empty();
}
return java.util.Optional.of(new UserPrincipal(userId, email, steamId));
} catch (Exception e) {
log.warn("Dev mode: JWT parsing failed: {}", e.getMessage());
return java.util.Optional.empty();
}
}
private UserPrincipal dummyPrincipal() {
log.trace("Dev mode: no JWT present, injecting dummy principal");
return new UserPrincipal("1", "dev@example.com", 76561198000000001L);
}
}
@@ -4,12 +4,10 @@ import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Getter
@Setter
@Configuration
@Profile("!dev")
@ConfigurationProperties(prefix = "security.jwt")
public class JwtConfig {
@@ -2,14 +2,12 @@ package core_service.configuration.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
@Service
@Profile("!dev")
public class JwtService {
private final JwtConfig jwtConfig;
@@ -0,0 +1,109 @@
package core_service.modules.library.api;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.PageDto;
import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.SyncCooldownException;
import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/library")
@RequiredArgsConstructor
public class LibraryController {
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
private static final int DEFAULT_SIZE = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid");
private final LibrarySyncService syncService;
private final LibraryQueryService queryService;
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) {
return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false));
}
Long userId = principal.getIdAsLong();
String steamId = String.valueOf(principal.steamId());
syncService.syncIfStale(userId, steamId);
long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true));
}
@GetMapping("/games")
public ResponseEntity<PageDto<GameDto>> getGames(
@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "playtimeMinutes,desc") String sort
) {
if (principal.steamId() == null) {
return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0));
}
Long userId = principal.getIdAsLong();
String steamId = String.valueOf(principal.steamId());
syncService.syncIfStale(userId, steamId);
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
Sort safeSort = parseSort(sort);
var dtoPage = queryService.listOwnedGames(userId, PageRequest.of(safePage, safeSize, safeSort));
return ResponseEntity.ok(PageDto.of(dtoPage));
}
private Sort parseSort(String sort) {
Sort fallback = Sort.by(Sort.Direction.DESC, "playtimeMinutes");
if (sort == null || sort.isBlank()) return fallback;
String[] parts = sort.split(",");
String field = parts[0].trim();
if (!SORTABLE_FIELDS.contains(field)) return fallback;
Sort.Direction dir = parts.length > 1 && "asc".equalsIgnoreCase(parts[1].trim())
? Sort.Direction.ASC : Sort.Direction.DESC;
return Sort.by(dir, field);
}
@PostMapping("/sync")
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) {
return ResponseEntity.badRequest().body(Map.of("code", "NO_STEAM_ID", "message", "User has no Steam ID"));
}
try {
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
return ResponseEntity.accepted().body(Map.of("status", "started"));
} catch (SyncCooldownException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds()))
.body(Map.of(
"code", "SYNC_COOLDOWN",
"message", e.getMessage(),
"retryAfterSeconds", e.getRetryAfterSeconds()));
}
}
@GetMapping("/sync-status")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
return queryService.getSyncStatus(principal.getIdAsLong())
.map(s -> new SyncStatusDto(
s.getLastSyncedAt(),
s.getLastSyncStatus() != null ? s.getLastSyncStatus().name() : null))
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.ok(new SyncStatusDto(null, null)));
}
}
@@ -0,0 +1,8 @@
package core_service.modules.library.api.dto;
public record GameDto(
Long appid,
String name,
String imgIconUrl,
int playtimeMinutes
) {}
@@ -0,0 +1,22 @@
package core_service.modules.library.api.dto;
import org.springframework.data.domain.Page;
import java.util.List;
public record PageDto<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageDto<T> of(Page<T> page) {
return new PageDto<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages());
}
}
@@ -0,0 +1,8 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
public record SyncStatusDto(
Instant lastSyncedAt,
String lastSyncStatus
) {}
@@ -0,0 +1,40 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Entity
@Table(name = "steam_game")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SteamGame {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "name", nullable = false, length = 512)
private String name;
@Column(name = "img_icon_url", length = 512)
private String imgIconUrl;
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt;
public SteamGame(Long appid, String name, String imgIconUrl, Instant lastSeenAt) {
this.appid = appid;
this.name = name;
this.imgIconUrl = imgIconUrl;
this.lastSeenAt = lastSeenAt;
}
}
@@ -0,0 +1,50 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Entity
@Table(name = "user_game")
@IdClass(UserGameId.class)
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserGame {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "playtime_minutes", nullable = false)
private int playtimeMinutes;
@Column(name = "first_seen_at", nullable = false)
private Instant firstSeenAt;
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt;
@Column(name = "removed_at")
private Instant removedAt;
public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) {
this.userId = userId;
this.appid = appid;
this.playtimeMinutes = playtimeMinutes;
this.firstSeenAt = firstSeenAt;
this.lastSeenAt = lastSeenAt;
}
}
@@ -0,0 +1,19 @@
package core_service.modules.library.domain;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class UserGameId implements Serializable {
private Long userId;
private Long appid;
}
@@ -0,0 +1,43 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Entity
@Table(name = "user_steam_sync")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserSteamSync {
public enum Status { OK, FAILED, RUNNING }
@Id
@Column(name = "user_id")
private Long userId;
@Column(name = "steam_id", nullable = false, length = 32)
private String steamId;
@Column(name = "last_synced_at")
private Instant lastSyncedAt;
@Enumerated(EnumType.STRING)
@Column(name = "last_sync_status", length = 16)
private Status lastSyncStatus;
public UserSteamSync(Long userId, String steamId) {
this.userId = userId;
this.steamId = steamId;
}
}
@@ -0,0 +1,7 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.SteamGame;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
}
@@ -0,0 +1,29 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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 java.time.Instant;
import java.util.List;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
Page<UserGame> findByUserIdAndRemovedAtIsNull(Long userId, Pageable pageable);
long countByUserIdAndRemovedAtIsNull(Long userId);
@Modifying
@Query("update UserGame ug set ug.removedAt = :now " +
"where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null")
int softDeleteMissingSince(@Param("userId") Long userId,
@Param("syncStartedAt") Instant syncStartedAt,
@Param("now") Instant now);
}
@@ -0,0 +1,44 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.UserSteamSync;
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 java.time.Instant;
public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Long> {
/**
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
* (row created, or existing row was not RUNNING / RUNNING marker stale).
* Returns 0 if another sync is already in flight — caller must skip.
*/
@Modifying
@Query(value = """
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status)
VALUES (:userId, :steamId, 'RUNNING')
ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING'
WHERE user_steam_sync.last_sync_status <> 'RUNNING'
OR user_steam_sync.last_synced_at IS NULL
OR user_steam_sync.last_synced_at < :runningStaleBefore
""", nativeQuery = true)
int tryClaimRun(@Param("userId") Long userId,
@Param("steamId") String steamId,
@Param("runningStaleBefore") Instant runningStaleBefore);
@Modifying
@Query(value = """
UPDATE user_steam_sync
SET last_synced_at = :now,
last_sync_status = :status
WHERE user_id = :userId
""", nativeQuery = true)
void markFinished(@Param("userId") Long userId,
@Param("status") String status,
@Param("now") Instant now);
}
@@ -0,0 +1,59 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LibraryQueryService {
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
private final SteamGameRepository steamGameRepository;
public long countOwnedGames(Long userId) {
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
}
public List<UserGame> listOwnedGames(Long userId) {
return userGameRepository.findByUserIdAndRemovedAtIsNull(userId);
}
public Page<GameDto> listOwnedGames(Long userId, Pageable pageable) {
Page<UserGame> page = userGameRepository.findByUserIdAndRemovedAtIsNull(userId, pageable);
if (page.isEmpty()) {
return page.map(ug -> new GameDto(ug.getAppid(), null, null, ug.getPlaytimeMinutes()));
}
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
return page.map(ug -> {
SteamGame g = meta.get(ug.getAppid());
return new GameDto(
ug.getAppid(),
g != null ? g.getName() : null,
g != null ? g.getImgIconUrl() : null,
ug.getPlaytimeMinutes());
});
}
public Optional<UserSteamSync> getSyncStatus(Long userId) {
return userSteamSyncRepository.findById(userId);
}
}
@@ -0,0 +1,73 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.UserSteamSyncRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
@Service
@RequiredArgsConstructor
@Slf4j
public class LibrarySyncService {
private final UserSteamSyncRepository userSteamSyncRepository;
private final LibrarySyncWorker worker;
@Value("${app.library.sync-ttl-hours:24}")
private long syncTtlHours;
@Value("${app.library.force-sync-cooldown-seconds:60}")
private long forceSyncCooldownSeconds;
@Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes;
/**
* Trigger sync only if data is older than TTL. Cheap gate check on caller thread,
* actual sync runs async via worker.
*/
public void syncIfStale(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
if (sync != null && !isStale(sync)) {
return;
}
if (sync != null && isAlreadyRunning(sync)) {
log.debug("Sync already running for user {}", userId);
return;
}
worker.runSync(userId, steamId);
}
/**
* User-triggered sync. Throws SyncCooldownException if recently synced or running.
*/
public void forceSync(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
if (sync != null && sync.getLastSyncedAt() != null) {
long sinceLast = Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds();
if (sinceLast < forceSyncCooldownSeconds) {
throw new SyncCooldownException(forceSyncCooldownSeconds - sinceLast);
}
}
if (sync != null && isAlreadyRunning(sync)) {
throw new SyncCooldownException(forceSyncCooldownSeconds);
}
worker.runSync(userId, steamId);
}
private boolean isStale(UserSteamSync sync) {
if (sync.getLastSyncedAt() == null) return true;
return sync.getLastSyncedAt().isBefore(Instant.now().minus(Duration.ofHours(syncTtlHours)));
}
private boolean isAlreadyRunning(UserSteamSync sync) {
if (sync.getLastSyncStatus() != UserSteamSync.Status.RUNNING) return false;
if (sync.getLastSyncedAt() == null) return true;
return sync.getLastSyncedAt().isAfter(Instant.now().minus(Duration.ofMinutes(runningStaleMinutes)));
}
}
@@ -0,0 +1,100 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import core_service.modules.library.domain.UserSteamSync.Status;
/**
* Worker bean. Runs the actual Steam fetch + DB upserts.
* Separate bean so Spring proxy applies @Async and @Transactional
* (self-invocation from LibrarySyncService would bypass both).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LibrarySyncWorker {
private final SteamApiService steamApiService;
private final SteamGameRepository steamGameRepository;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
@Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes;
@Async
@Transactional
public void runSync(Long userId, String steamId) {
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
if (claimed == 0) {
log.info("Sync skipped for user {} — another run already in flight", userId);
return;
}
Instant syncStartedAt = Instant.now();
Status finalStatus = Status.FAILED;
try {
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId);
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
for (SteamOwnedGamesResponse.Game g : games) {
if (g.appid() == null) continue;
upsertGame(g, syncStartedAt);
upsertUserGame(userId, g, syncStartedAt);
}
int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
if (removed > 0) {
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
}
finalStatus = Status.OK;
} catch (Exception e) {
log.error("Sync failed for user {}", userId, e);
} finally {
userSteamSyncRepository.markFinished(userId, finalStatus.name(), Instant.now());
}
}
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now) {
Long appid = g.appid().longValue();
SteamGame existing = steamGameRepository.findById(appid).orElse(null);
if (existing == null) {
steamGameRepository.save(new SteamGame(appid, g.name(), g.imgIconUrl(), now));
} else {
existing.setName(g.name());
existing.setImgIconUrl(g.imgIconUrl());
existing.setLastSeenAt(now);
}
}
private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now) {
Long appid = g.appid().longValue();
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
if (existing == null) {
userGameRepository.save(new UserGame(userId, appid, playtime, now, now));
} else {
existing.setPlaytimeMinutes(playtime);
existing.setLastSeenAt(now);
existing.setRemovedAt(null);
}
}
}
@@ -0,0 +1,14 @@
package core_service.modules.library.service;
public class SyncCooldownException extends RuntimeException {
private final long retryAfterSeconds;
public SyncCooldownException(long retryAfterSeconds) {
super("Sync on cooldown, retry in " + retryAfterSeconds + "s");
this.retryAfterSeconds = retryAfterSeconds;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
}
}
@@ -3,6 +3,8 @@ package core_service.modules.steam_adapter.service;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -25,6 +27,8 @@ public class SteamApiService {
private String apiKey;
@Cacheable(value = "steamGameCount", key = "#steamId")
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public int getOwnedGamesCount(String steamId) {
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
log.error("Steam API Key is not configured correctly!");
@@ -81,6 +85,8 @@ public class SteamApiService {
return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games));
}
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public Optional<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
try {
log.info("Fetching Steam profile for SteamID: {}", steamId);
@@ -105,6 +111,8 @@ public class SteamApiService {
return Optional.empty();
}
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public List<SteamOwnedGamesResponse.Game> getOwnedGames(String steamId) {
try {
log.info("Fetching owned games for SteamID: {}", steamId);
+23
View File
@@ -44,3 +44,26 @@ app:
api-key: ${STEAM_API_KEY}
base-url: https://api.steampowered.com
imageService-url: https://cdn.cloudflare.steamstatic.com/steam/apps/
library:
sync-ttl-hours: 24
force-sync-cooldown-seconds: 60
running-stale-minutes: 5
resilience4j:
retry:
instances:
steamApi:
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- org.springframework.web.client.RestClientException
- org.springframework.web.client.ResourceAccessException
ratelimiter:
instances:
steamApi:
# Steam allows ~100k req/day. Cap at ~2 req/s to leave headroom for bursts.
limit-for-period: 2
limit-refresh-period: 1s
timeout-duration: 5s
@@ -0,0 +1,27 @@
-- Global Steam game catalog. Shared across users to avoid duplicating metadata.
CREATE TABLE steam_game (
appid BIGINT PRIMARY KEY,
name VARCHAR(512) NOT NULL,
img_icon_url VARCHAR(512),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Per-user ownership and playtime. Soft-delete via removed_at.
CREATE TABLE user_game (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL REFERENCES steam_game(appid),
playtime_minutes INTEGER NOT NULL DEFAULT 0,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
PRIMARY KEY (user_id, appid)
);
CREATE INDEX idx_user_game_user_active ON user_game (user_id) WHERE removed_at IS NULL;
-- Per-user Steam sync state. Owned by Core to avoid coupling Auth schema.
CREATE TABLE user_steam_sync (
user_id BIGINT PRIMARY KEY,
steam_id VARCHAR(32) NOT NULL,
last_synced_at TIMESTAMPTZ,
last_sync_status VARCHAR(16)
);