Public Access
Added new endpoints to sync the list of games from steam if a user logged in.
This commit is contained in:
1 parent
ac97442312
commit
2f3507b57b
23 files changed
+749
-18
No files matched your search
@@ -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,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 76561198000000001
|
||||
UserPrincipal principal = new UserPrincipal("1", "dev@example.com", 76561198000000001L);
|
||||
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<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);
|
||||
|
||||
@@ -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/
|
||||
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)
|
||||
);
|
||||
Reference in new issue
Block a user