diff --git a/pom.xml b/pom.xml
index ead3ec1..6017c34 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,6 +17,7 @@
0.13.0
1.21.4
1.4.1
+ 2.2.0
@@ -45,6 +46,12 @@
com.github.ben-manes.caffeine
caffeine
+
+
+ io.github.resilience4j
+ resilience4j-spring-boot3
+ ${resilience4j.version}
+
io.jsonwebtoken
diff --git a/src/main/java/core_service/configuration/AsyncConfig.java b/src/main/java/core_service/configuration/AsyncConfig.java
new file mode 100644
index 0000000..cbcc1c0
--- /dev/null
+++ b/src/main/java/core_service/configuration/AsyncConfig.java
@@ -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 {
+}
diff --git a/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java b/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java
index 5f5096d..01fbe2d 100644
--- a/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java
+++ b/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java
@@ -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,19 +38,50 @@ 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 76561198005679788
- UserPrincipal principal = new UserPrincipal("1", "dev@example.com", 76561198005679788L);
- UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
- principal,
- null,
- Collections.emptyList()
- );
- authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
- SecurityContextHolder.getContext().setAuthentication(authToken);
- log.trace("Dev mode: Injected default user principal with SteamID: {}", principal.steamId());
+ if (SecurityContextHolder.getContext().getAuthentication() != null) {
+ filterChain.doFilter(request, response);
+ return;
}
+ UserPrincipal principal = principalFromJwt(request).orElseGet(this::dummyPrincipal);
+
+ UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
+ principal,
+ null,
+ Collections.emptyList()
+ );
+ authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+
filterChain.doFilter(request, response);
}
+
+ private java.util.Optional 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", 76561198005679788L);
+ }
}
diff --git a/src/main/java/core_service/configuration/security/JwtConfig.java b/src/main/java/core_service/configuration/security/JwtConfig.java
index 311f72a..62ccd8c 100644
--- a/src/main/java/core_service/configuration/security/JwtConfig.java
+++ b/src/main/java/core_service/configuration/security/JwtConfig.java
@@ -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 {
diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java
index 9046abd..8bbc969 100644
--- a/src/main/java/core_service/configuration/security/JwtService.java
+++ b/src/main/java/core_service/configuration/security/JwtService.java
@@ -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;
diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java
new file mode 100644
index 0000000..2d7159e
--- /dev/null
+++ b/src/main/java/core_service/modules/library/api/LibraryController.java
@@ -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 ALLOWED_SIZES = Set.of(20, 40, 80);
+ private static final int DEFAULT_SIZE = 20;
+ private static final Set 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> 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 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)));
+ }
+}
diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java
new file mode 100644
index 0000000..5033e63
--- /dev/null
+++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java
@@ -0,0 +1,8 @@
+package core_service.modules.library.api.dto;
+
+public record GameDto(
+ Long appid,
+ String name,
+ String imgIconUrl,
+ int playtimeMinutes
+) {}
diff --git a/src/main/java/core_service/modules/library/api/dto/PageDto.java b/src/main/java/core_service/modules/library/api/dto/PageDto.java
new file mode 100644
index 0000000..8f7a48f
--- /dev/null
+++ b/src/main/java/core_service/modules/library/api/dto/PageDto.java
@@ -0,0 +1,22 @@
+package core_service.modules.library.api.dto;
+
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+public record PageDto(
+ List content,
+ int page,
+ int size,
+ long totalElements,
+ int totalPages
+) {
+ public static PageDto of(Page page) {
+ return new PageDto<>(
+ page.getContent(),
+ page.getNumber(),
+ page.getSize(),
+ page.getTotalElements(),
+ page.getTotalPages());
+ }
+}
diff --git a/src/main/java/core_service/modules/library/api/dto/SyncStatusDto.java b/src/main/java/core_service/modules/library/api/dto/SyncStatusDto.java
new file mode 100644
index 0000000..f1572a6
--- /dev/null
+++ b/src/main/java/core_service/modules/library/api/dto/SyncStatusDto.java
@@ -0,0 +1,8 @@
+package core_service.modules.library.api.dto;
+
+import java.time.Instant;
+
+public record SyncStatusDto(
+ Instant lastSyncedAt,
+ String lastSyncStatus
+) {}
diff --git a/src/main/java/core_service/modules/library/domain/SteamGame.java b/src/main/java/core_service/modules/library/domain/SteamGame.java
new file mode 100644
index 0000000..6df1b23
--- /dev/null
+++ b/src/main/java/core_service/modules/library/domain/SteamGame.java
@@ -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;
+ }
+}
diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java
new file mode 100644
index 0000000..4f0eadd
--- /dev/null
+++ b/src/main/java/core_service/modules/library/domain/UserGame.java
@@ -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;
+ }
+}
diff --git a/src/main/java/core_service/modules/library/domain/UserGameId.java b/src/main/java/core_service/modules/library/domain/UserGameId.java
new file mode 100644
index 0000000..eb1cb9e
--- /dev/null
+++ b/src/main/java/core_service/modules/library/domain/UserGameId.java
@@ -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;
+}
diff --git a/src/main/java/core_service/modules/library/domain/UserSteamSync.java b/src/main/java/core_service/modules/library/domain/UserSteamSync.java
new file mode 100644
index 0000000..b6cc1e5
--- /dev/null
+++ b/src/main/java/core_service/modules/library/domain/UserSteamSync.java
@@ -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;
+ }
+}
diff --git a/src/main/java/core_service/modules/library/repo/SteamGameRepository.java b/src/main/java/core_service/modules/library/repo/SteamGameRepository.java
new file mode 100644
index 0000000..ad6a811
--- /dev/null
+++ b/src/main/java/core_service/modules/library/repo/SteamGameRepository.java
@@ -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 {
+}
diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java
new file mode 100644
index 0000000..b9b6b68
--- /dev/null
+++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java
@@ -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 {
+
+ List findByUserIdAndRemovedAtIsNull(Long userId);
+
+ Page 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);
+}
diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java
new file mode 100644
index 0000000..f8205f5
--- /dev/null
+++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java
@@ -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 {
+
+ /**
+ * 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);
+}
+
diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java
new file mode 100644
index 0000000..62dbc4c
--- /dev/null
+++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java
@@ -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 listOwnedGames(Long userId) {
+ return userGameRepository.findByUserIdAndRemovedAtIsNull(userId);
+ }
+
+ public Page listOwnedGames(Long userId, Pageable pageable) {
+ Page page = userGameRepository.findByUserIdAndRemovedAtIsNull(userId, pageable);
+ if (page.isEmpty()) {
+ return page.map(ug -> new GameDto(ug.getAppid(), null, null, ug.getPlaytimeMinutes()));
+ }
+ List appids = page.getContent().stream().map(UserGame::getAppid).toList();
+ Map 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 getSyncStatus(Long userId) {
+ return userSteamSyncRepository.findById(userId);
+ }
+}
diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncService.java b/src/main/java/core_service/modules/library/service/LibrarySyncService.java
new file mode 100644
index 0000000..36a9ae3
--- /dev/null
+++ b/src/main/java/core_service/modules/library/service/LibrarySyncService.java
@@ -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)));
+ }
+}
diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java
new file mode 100644
index 0000000..67d382d
--- /dev/null
+++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java
@@ -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 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);
+ }
+ }
+}
diff --git a/src/main/java/core_service/modules/library/service/SyncCooldownException.java b/src/main/java/core_service/modules/library/service/SyncCooldownException.java
new file mode 100644
index 0000000..cff1403
--- /dev/null
+++ b/src/main/java/core_service/modules/library/service/SyncCooldownException.java
@@ -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;
+ }
+}
diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java
index 6b8e353..122ce36 100644
--- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java
+++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java
@@ -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 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 getOwnedGames(String steamId) {
try {
log.info("Fetching owned games for SteamID: {}", steamId);
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 7268cb0..22739fd 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -43,4 +43,27 @@ app:
steam:
api-key: ${STEAM_API_KEY}
base-url: https://api.steampowered.com
- imageService-url: https://cdn.cloudflare.steamstatic.com/steam/apps/
\ No newline at end of file
+ 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
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V1__init_library.sql b/src/main/resources/db/migration/V1__init_library.sql
new file mode 100644
index 0000000..a9c0c0f
--- /dev/null
+++ b/src/main/resources/db/migration/V1__init_library.sql
@@ -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)
+);