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) +);