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
+748
-17
No files matched your search
@@ -17,6 +17,7 @@
|
|||||||
<jwt.version>0.13.0</jwt.version>
|
<jwt.version>0.13.0</jwt.version>
|
||||||
<testcontainers.version>1.21.4</testcontainers.version>
|
<testcontainers.version>1.21.4</testcontainers.version>
|
||||||
<archunit.version>1.4.1</archunit.version>
|
<archunit.version>1.4.1</archunit.version>
|
||||||
|
<resilience4j.version>2.2.0</resilience4j.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- Spring -->
|
<!-- Spring -->
|
||||||
@@ -45,6 +46,12 @@
|
|||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
<artifactId>caffeine</artifactId>
|
<artifactId>caffeine</artifactId>
|
||||||
</dependency>
|
</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 -->
|
<!-- JWT -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<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.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jspecify.annotations.NonNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
@@ -18,14 +19,18 @@ import java.io.IOException;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Development-specific authentication filter that bypasses real JWT validation.
|
* Dev profile authentication filter.
|
||||||
* It automatically injects a default user principal for every request when the 'dev' profile is active.
|
* - 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")
|
@Component("jwtAuthenticationFilter")
|
||||||
@Profile("dev")
|
@Profile("dev")
|
||||||
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
|
public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(
|
protected void doFilterInternal(
|
||||||
@NonNull HttpServletRequest request,
|
@NonNull HttpServletRequest request,
|
||||||
@@ -33,19 +38,50 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
@NonNull FilterChain filterChain
|
@NonNull FilterChain filterChain
|
||||||
) throws ServletException, IOException {
|
) throws ServletException, IOException {
|
||||||
|
|
||||||
if (SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (SecurityContextHolder.getContext().getAuthentication() != null) {
|
||||||
// Default user principal for dev mode: Using the requested Steam ID 76561198000000001
|
filterChain.doFilter(request, response);
|
||||||
UserPrincipal principal = new UserPrincipal("1", "dev@example.com", 76561198000000001L);
|
return;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
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 lombok.Setter;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@Configuration
|
@Configuration
|
||||||
@Profile("!dev")
|
|
||||||
@ConfigurationProperties(prefix = "security.jwt")
|
@ConfigurationProperties(prefix = "security.jwt")
|
||||||
public class JwtConfig {
|
public class JwtConfig {
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package core_service.configuration.security;
|
|||||||
|
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import io.jsonwebtoken.security.Keys;
|
import io.jsonwebtoken.security.Keys;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Profile("!dev")
|
|
||||||
public class JwtService {
|
public class JwtService {
|
||||||
|
|
||||||
private final JwtConfig jwtConfig;
|
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.SteamOwnedGamesResponse;
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -25,6 +27,8 @@ public class SteamApiService {
|
|||||||
private String apiKey;
|
private String apiKey;
|
||||||
|
|
||||||
@Cacheable(value = "steamGameCount", key = "#steamId")
|
@Cacheable(value = "steamGameCount", key = "#steamId")
|
||||||
|
@RateLimiter(name = "steamApi")
|
||||||
|
@Retry(name = "steamApi")
|
||||||
public int getOwnedGamesCount(String steamId) {
|
public int getOwnedGamesCount(String steamId) {
|
||||||
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
|
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
|
||||||
log.error("Steam API Key is not configured correctly!");
|
log.error("Steam API Key is not configured correctly!");
|
||||||
@@ -81,6 +85,8 @@ public class SteamApiService {
|
|||||||
return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games));
|
return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RateLimiter(name = "steamApi")
|
||||||
|
@Retry(name = "steamApi")
|
||||||
public Optional<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
|
public Optional<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
|
||||||
try {
|
try {
|
||||||
log.info("Fetching Steam profile for SteamID: {}", steamId);
|
log.info("Fetching Steam profile for SteamID: {}", steamId);
|
||||||
@@ -105,6 +111,8 @@ public class SteamApiService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RateLimiter(name = "steamApi")
|
||||||
|
@Retry(name = "steamApi")
|
||||||
public List<SteamOwnedGamesResponse.Game> getOwnedGames(String steamId) {
|
public List<SteamOwnedGamesResponse.Game> getOwnedGames(String steamId) {
|
||||||
try {
|
try {
|
||||||
log.info("Fetching owned games for SteamID: {}", steamId);
|
log.info("Fetching owned games for SteamID: {}", steamId);
|
||||||
|
|||||||
@@ -44,3 +44,26 @@ app:
|
|||||||
api-key: ${STEAM_API_KEY}
|
api-key: ${STEAM_API_KEY}
|
||||||
base-url: https://api.steampowered.com
|
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