Public Access
Added more insight to the games of a user
This commit is contained in:
1 parent
2f3507b57b
commit
6b91463c4a
27 files changed
+873
-8
No files matched your search
@@ -6,7 +6,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class CoreServiceApplication {
|
public class CoreServiceApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
static void main(String[] args) {
|
||||||
SpringApplication.run(CoreServiceApplication.class, args);
|
SpringApplication.run(CoreServiceApplication.class, args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ public class CacheConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount");
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats");
|
||||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||||
.expireAfterWrite(1, TimeUnit.HOURS)
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
.maximumSize(1000));
|
.maximumSize(1000));
|
||||||
return cacheManager;
|
return cacheManager;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ public class SecurityConfig {
|
|||||||
"/actuator/health",
|
"/actuator/health",
|
||||||
"/actuator/prometheus",
|
"/actuator/prometheus",
|
||||||
"/error",
|
"/error",
|
||||||
"/api/steam/test/**"
|
"/api/steam/test/**",
|
||||||
|
"/api/stats/public"
|
||||||
};
|
};
|
||||||
|
|
||||||
private final OncePerRequestFilter jwtAuthFilter;
|
private final OncePerRequestFilter jwtAuthFilter;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package core_service.modules.library.api;
|
|||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.PageDto;
|
import core_service.modules.library.api.dto.PageDto;
|
||||||
import core_service.modules.library.api.dto.SyncStatusDto;
|
import core_service.modules.library.api.dto.SyncStatusDto;
|
||||||
|
import core_service.modules.library.service.GameDetailService;
|
||||||
import core_service.modules.library.service.LibraryQueryService;
|
import core_service.modules.library.service.LibraryQueryService;
|
||||||
import core_service.modules.library.service.LibrarySyncService;
|
import core_service.modules.library.service.LibrarySyncService;
|
||||||
import core_service.modules.library.service.SyncCooldownException;
|
import core_service.modules.library.service.SyncCooldownException;
|
||||||
@@ -15,6 +16,7 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -34,6 +36,7 @@ public class LibraryController {
|
|||||||
|
|
||||||
private final LibrarySyncService syncService;
|
private final LibrarySyncService syncService;
|
||||||
private final LibraryQueryService queryService;
|
private final LibraryQueryService queryService;
|
||||||
|
private final GameDetailService gameDetailService;
|
||||||
|
|
||||||
@GetMapping("/games-count")
|
@GetMapping("/games-count")
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
@@ -97,6 +100,20 @@ public class LibraryController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/games/{appid}")
|
||||||
|
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid) {
|
||||||
|
if (principal.steamId() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("code", "NO_STEAM_ID", "message", "User has no Steam ID"));
|
||||||
|
}
|
||||||
|
Long userId = principal.getIdAsLong();
|
||||||
|
String steamId = String.valueOf(principal.steamId());
|
||||||
|
return gameDetailService.getGameDetail(userId, steamId, appid)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/sync-status")
|
@GetMapping("/sync-status")
|
||||||
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
return queryService.getSyncStatus(principal.getIdAsLong())
|
return queryService.getSyncStatus(principal.getIdAsLong())
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record AchievementDto(
|
||||||
|
String apiName,
|
||||||
|
String displayName,
|
||||||
|
String description,
|
||||||
|
String iconUrl,
|
||||||
|
String iconGrayUrl,
|
||||||
|
boolean hidden,
|
||||||
|
boolean unlocked,
|
||||||
|
Instant unlockedAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record GameDetailDto(
|
||||||
|
Long appid,
|
||||||
|
String name,
|
||||||
|
String imgIconUrl,
|
||||||
|
int playtimeMinutes,
|
||||||
|
Instant firstSeenAt,
|
||||||
|
Instant lastSeenAt,
|
||||||
|
boolean hasAchievements,
|
||||||
|
boolean statsVisible,
|
||||||
|
int achievementsTotal,
|
||||||
|
int achievementsUnlocked,
|
||||||
|
int achievementsRemaining,
|
||||||
|
double completionPercent,
|
||||||
|
List<AchievementDto> achievements
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "game_achievement_schema")
|
||||||
|
@IdClass(GameAchievementSchemaId.class)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
public class GameAchievementSchema {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appid")
|
||||||
|
private Long appid;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "api_name", length = 256)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Column(name = "display_name", length = 512)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 2048)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "icon_url", length = 512)
|
||||||
|
private String iconUrl;
|
||||||
|
|
||||||
|
@Column(name = "icon_gray_url", length = 512)
|
||||||
|
private String iconGrayUrl;
|
||||||
|
|
||||||
|
@Column(name = "hidden", nullable = false)
|
||||||
|
private boolean hidden;
|
||||||
|
|
||||||
|
public GameAchievementSchema(Long appid, String apiName, String displayName, String description,
|
||||||
|
String iconUrl, String iconGrayUrl, boolean hidden) {
|
||||||
|
this.appid = appid;
|
||||||
|
this.apiName = apiName;
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.description = description;
|
||||||
|
this.iconUrl = iconUrl;
|
||||||
|
this.iconGrayUrl = iconGrayUrl;
|
||||||
|
this.hidden = hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 GameAchievementSchemaId implements Serializable {
|
||||||
|
private Long appid;
|
||||||
|
private String apiName;
|
||||||
|
}
|
||||||
@@ -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 = "game_achievement_schema_meta")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
public class GameAchievementSchemaMeta {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appid")
|
||||||
|
private Long appid;
|
||||||
|
|
||||||
|
@Column(name = "achievements_total", nullable = false)
|
||||||
|
private int achievementsTotal;
|
||||||
|
|
||||||
|
@Column(name = "has_schema", nullable = false)
|
||||||
|
private boolean hasSchema;
|
||||||
|
|
||||||
|
@Column(name = "refreshed_at", nullable = false)
|
||||||
|
private Instant refreshedAt;
|
||||||
|
|
||||||
|
public GameAchievementSchemaMeta(Long appid, int achievementsTotal, boolean hasSchema, Instant refreshedAt) {
|
||||||
|
this.appid = appid;
|
||||||
|
this.achievementsTotal = achievementsTotal;
|
||||||
|
this.hasSchema = hasSchema;
|
||||||
|
this.refreshedAt = refreshedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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_achievement")
|
||||||
|
@IdClass(UserGameAchievementId.class)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
public class UserGameAchievement {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appid")
|
||||||
|
private Long appid;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "api_name", length = 256)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Column(name = "unlocked", nullable = false)
|
||||||
|
private boolean unlocked;
|
||||||
|
|
||||||
|
@Column(name = "unlocked_at")
|
||||||
|
private Instant unlockedAt;
|
||||||
|
|
||||||
|
public UserGameAchievement(Long userId, Long appid, String apiName, boolean unlocked, Instant unlockedAt) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.appid = appid;
|
||||||
|
this.apiName = apiName;
|
||||||
|
this.unlocked = unlocked;
|
||||||
|
this.unlockedAt = unlockedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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 UserGameAchievementId implements Serializable {
|
||||||
|
private Long userId;
|
||||||
|
private Long appid;
|
||||||
|
private String apiName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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_achievement_meta")
|
||||||
|
@IdClass(UserGameId.class)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
public class UserGameAchievementMeta {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appid")
|
||||||
|
private Long appid;
|
||||||
|
|
||||||
|
@Column(name = "refreshed_at", nullable = false)
|
||||||
|
private Instant refreshedAt;
|
||||||
|
|
||||||
|
@Column(name = "stats_visible", nullable = false)
|
||||||
|
private boolean statsVisible;
|
||||||
|
|
||||||
|
@Column(name = "achievements_unlocked", nullable = false)
|
||||||
|
private int achievementsUnlocked;
|
||||||
|
|
||||||
|
public UserGameAchievementMeta(Long userId, Long appid, Instant refreshedAt, boolean statsVisible, int achievementsUnlocked) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.appid = appid;
|
||||||
|
this.refreshedAt = refreshedAt;
|
||||||
|
this.statsVisible = statsVisible;
|
||||||
|
this.achievementsUnlocked = achievementsUnlocked;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface GameAchievementSchemaMetaRepository extends JpaRepository<GameAchievementSchemaMeta, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchema;
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchemaId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface GameAchievementSchemaRepository extends JpaRepository<GameAchievementSchema, GameAchievementSchemaId> {
|
||||||
|
|
||||||
|
List<GameAchievementSchema> findByAppid(Long appid);
|
||||||
|
|
||||||
|
void deleteByAppid(Long appid);
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
|
import core_service.modules.library.domain.UserGameId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface UserGameAchievementMetaRepository extends JpaRepository<UserGameAchievementMeta, UserGameId> {
|
||||||
|
|
||||||
|
Optional<UserGameAchievementMeta> findByUserIdAndAppid(Long userId, Long appid);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.UserGameAchievement;
|
||||||
|
import core_service.modules.library.domain.UserGameAchievementId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
|
||||||
|
|
||||||
|
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
|
||||||
|
|
||||||
|
void deleteByUserIdAndAppid(Long userId, Long appid);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.api.dto.AchievementDto;
|
||||||
|
import core_service.modules.library.api.dto.GameDetailDto;
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchema;
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.domain.UserGameAchievement;
|
||||||
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
|
import core_service.modules.library.domain.UserGameId;
|
||||||
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
|
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
||||||
|
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.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class GameDetailService {
|
||||||
|
|
||||||
|
private final UserGameRepository userGameRepository;
|
||||||
|
private final SteamGameRepository steamGameRepository;
|
||||||
|
private final GameAchievementSchemaRepository schemaRepository;
|
||||||
|
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||||
|
private final UserGameAchievementRepository userAchievementRepository;
|
||||||
|
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||||
|
private final SteamApiService steamApiService;
|
||||||
|
|
||||||
|
@Value("${app.library.achievement-ttl-hours:1}")
|
||||||
|
private long achievementTtlHours;
|
||||||
|
|
||||||
|
@Value("${app.library.schema-ttl-days:30}")
|
||||||
|
private long schemaTtlDays;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
|
||||||
|
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
|
||||||
|
if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
UserGame userGame = userGameOpt.get();
|
||||||
|
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
|
||||||
|
|
||||||
|
refreshSchemaIfStale(appid);
|
||||||
|
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
|
||||||
|
|
||||||
|
if (!hasSchema) {
|
||||||
|
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshUserAchievementsIfStale(userId, steamId, appid);
|
||||||
|
|
||||||
|
UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
|
||||||
|
boolean statsVisible = userMeta == null || userMeta.isStatsVisible();
|
||||||
|
int unlocked = userMeta != null ? userMeta.getAchievementsUnlocked() : 0;
|
||||||
|
|
||||||
|
List<GameAchievementSchema> schemaList = schemaRepository.findByAppid(appid);
|
||||||
|
Map<String, UserGameAchievement> userMap = new HashMap<>();
|
||||||
|
for (UserGameAchievement uga : userAchievementRepository.findByUserIdAndAppid(userId, appid)) {
|
||||||
|
userMap.put(uga.getApiName(), uga);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AchievementDto> achievementDtos = schemaList.stream()
|
||||||
|
.map(s -> {
|
||||||
|
UserGameAchievement u = userMap.get(s.getApiName());
|
||||||
|
boolean isUnlocked = u != null && u.isUnlocked();
|
||||||
|
return new AchievementDto(
|
||||||
|
s.getApiName(),
|
||||||
|
s.getDisplayName(),
|
||||||
|
s.getDescription(),
|
||||||
|
s.getIconUrl(),
|
||||||
|
s.getIconGrayUrl(),
|
||||||
|
s.isHidden(),
|
||||||
|
isUnlocked,
|
||||||
|
u != null ? u.getUnlockedAt() : null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(), unlocked, achievementDtos));
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible,
|
||||||
|
int total, int unlocked, List<AchievementDto> achievements) {
|
||||||
|
boolean hasAchievements = total > 0;
|
||||||
|
int remaining = Math.max(0, total - unlocked);
|
||||||
|
double pct = total == 0 ? 0.0 : (unlocked * 100.0 / total);
|
||||||
|
return new GameDetailDto(
|
||||||
|
userGame.getAppid(),
|
||||||
|
meta != null ? meta.getName() : null,
|
||||||
|
meta != null ? meta.getImgIconUrl() : null,
|
||||||
|
userGame.getPlaytimeMinutes(),
|
||||||
|
userGame.getFirstSeenAt(),
|
||||||
|
userGame.getLastSeenAt(),
|
||||||
|
hasAchievements,
|
||||||
|
statsVisible,
|
||||||
|
total,
|
||||||
|
unlocked,
|
||||||
|
remaining,
|
||||||
|
Math.round(pct * 10.0) / 10.0,
|
||||||
|
achievements
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshSchemaIfStale(Long appid) {
|
||||||
|
Optional<GameAchievementSchemaMeta> existing = schemaMetaRepository.findById(appid);
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofDays(schemaTtlDays)))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<SteamSchemaForGameResponse.Game> schema = steamApiService.getSchemaForGame(appid);
|
||||||
|
boolean hasSchema = schema.isPresent()
|
||||||
|
&& schema.get().availableGameStats() != null
|
||||||
|
&& schema.get().availableGameStats().achievements() != null
|
||||||
|
&& !schema.get().availableGameStats().achievements().isEmpty();
|
||||||
|
|
||||||
|
if (hasSchema) {
|
||||||
|
schemaRepository.deleteByAppid(appid);
|
||||||
|
List<GameAchievementSchema> rows = schema.get().availableGameStats().achievements().stream()
|
||||||
|
.map(a -> new GameAchievementSchema(
|
||||||
|
appid,
|
||||||
|
a.name(),
|
||||||
|
a.displayName(),
|
||||||
|
a.description(),
|
||||||
|
a.icon(),
|
||||||
|
a.iconGray(),
|
||||||
|
a.hidden() != null && a.hidden() == 1
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
schemaRepository.saveAll(rows);
|
||||||
|
}
|
||||||
|
int total = hasSchema ? schema.get().availableGameStats().achievements().size() : 0;
|
||||||
|
schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, total, hasSchema, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshUserAchievementsIfStale(Long userId, String steamId, Long appid) {
|
||||||
|
Optional<UserGameAchievementMeta> existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid);
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofHours(achievementTtlHours)))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
|
||||||
|
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, false, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SteamPlayerAchievementsResponse.Achievement> list = response.get().achievements();
|
||||||
|
userAchievementRepository.deleteByUserIdAndAppid(userId, appid);
|
||||||
|
int unlockedCount = 0;
|
||||||
|
if (list != null) {
|
||||||
|
List<UserGameAchievement> rows = list.stream()
|
||||||
|
.map(a -> {
|
||||||
|
boolean unlocked = a.achieved() != null && a.achieved() == 1;
|
||||||
|
Instant unlockedAt = unlocked && a.unlockTime() != null && a.unlockTime() > 0
|
||||||
|
? Instant.ofEpochSecond(a.unlockTime()) : null;
|
||||||
|
return new UserGameAchievement(userId, appid, a.apiName(), unlocked, unlockedAt);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
userAchievementRepository.saveAll(rows);
|
||||||
|
unlockedCount = (int) rows.stream().filter(UserGameAchievement::isUnlocked).count();
|
||||||
|
}
|
||||||
|
userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, true, unlockedCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,6 @@ public class LibraryQueryService {
|
|||||||
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
|
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<UserGame> listOwnedGames(Long userId) {
|
|
||||||
return userGameRepository.findByUserIdAndRemovedAtIsNull(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Page<GameDto> listOwnedGames(Long userId, Pageable pageable) {
|
public Page<GameDto> listOwnedGames(Long userId, Pageable pageable) {
|
||||||
Page<UserGame> page = userGameRepository.findByUserIdAndRemovedAtIsNull(userId, pageable);
|
Page<UserGame> page = userGameRepository.findByUserIdAndRemovedAtIsNull(userId, pageable);
|
||||||
if (page.isEmpty()) {
|
if (page.isEmpty()) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package core_service.modules.stats.api;
|
||||||
|
|
||||||
|
import core_service.modules.stats.api.dto.PublicStatsDto;
|
||||||
|
import core_service.modules.stats.service.PublicStatsService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stats")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicStatsController {
|
||||||
|
|
||||||
|
private final PublicStatsService publicStatsService;
|
||||||
|
|
||||||
|
@GetMapping("/public")
|
||||||
|
public ResponseEntity<PublicStatsDto> getPublicStats() {
|
||||||
|
return ResponseEntity.ok(publicStatsService.getPublicStats());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package core_service.modules.stats.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record PublicStatsDto(
|
||||||
|
long totalDistinctGames,
|
||||||
|
long totalLibraryEntries,
|
||||||
|
long totalGamesPlayed,
|
||||||
|
long totalPlaytimeMinutes,
|
||||||
|
long totalPlaytimeDays,
|
||||||
|
long totalUsersWithLibrary,
|
||||||
|
long avgGamesPerUser,
|
||||||
|
long biggestLibrary,
|
||||||
|
long biggestBacklog,
|
||||||
|
List<TopGame> topOwnedGames,
|
||||||
|
List<TopGame> topPlayedGames
|
||||||
|
) {
|
||||||
|
public record TopGame(Long appid, String name, String imgIconUrl, long value) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package core_service.modules.stats.repo;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class StatsRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public StatsRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countDistinctGames() {
|
||||||
|
Long v = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE removed_at IS NULL", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countLibraryEntries() {
|
||||||
|
Long v = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countGamesPlayed() {
|
||||||
|
Long v = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND playtime_minutes > 0", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long sumPlaytimeMinutes() {
|
||||||
|
Long v = jdbc.queryForObject(
|
||||||
|
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE removed_at IS NULL", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUsersWithLibrary() {
|
||||||
|
Long v = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(DISTINCT user_id) FROM user_game WHERE removed_at IS NULL", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long biggestLibrary() {
|
||||||
|
Long v = jdbc.queryForObject("""
|
||||||
|
SELECT COALESCE(MAX(c), 0) FROM (
|
||||||
|
SELECT COUNT(*) AS c FROM user_game
|
||||||
|
WHERE removed_at IS NULL GROUP BY user_id
|
||||||
|
) t
|
||||||
|
""", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long biggestBacklog() {
|
||||||
|
Long v = jdbc.queryForObject("""
|
||||||
|
SELECT COALESCE(MAX(c), 0) FROM (
|
||||||
|
SELECT COUNT(*) AS c FROM user_game
|
||||||
|
WHERE removed_at IS NULL AND playtime_minutes = 0 GROUP BY user_id
|
||||||
|
) t
|
||||||
|
""", Long.class);
|
||||||
|
return v != null ? v : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> topOwnedGames(int limit) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT ug.appid, sg.name, sg.img_icon_url, COUNT(*) AS value
|
||||||
|
FROM user_game ug
|
||||||
|
LEFT JOIN steam_game sg ON sg.appid = ug.appid
|
||||||
|
WHERE ug.removed_at IS NULL
|
||||||
|
GROUP BY ug.appid, sg.name, sg.img_icon_url
|
||||||
|
ORDER BY value DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> topPlayedGames(int limit) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT ug.appid, sg.name, sg.img_icon_url, SUM(ug.playtime_minutes) AS value
|
||||||
|
FROM user_game ug
|
||||||
|
LEFT JOIN steam_game sg ON sg.appid = ug.appid
|
||||||
|
WHERE ug.removed_at IS NULL
|
||||||
|
GROUP BY ug.appid, sg.name, sg.img_icon_url
|
||||||
|
ORDER BY value DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package core_service.modules.stats.service;
|
||||||
|
|
||||||
|
import core_service.modules.stats.api.dto.PublicStatsDto;
|
||||||
|
import core_service.modules.stats.repo.StatsRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicStatsService {
|
||||||
|
|
||||||
|
private static final int TOP_LIMIT = 5;
|
||||||
|
|
||||||
|
private final StatsRepository statsRepository;
|
||||||
|
|
||||||
|
@Cacheable(value = "publicStats", key = "'global'")
|
||||||
|
public PublicStatsDto getPublicStats() {
|
||||||
|
long distinctGames = statsRepository.countDistinctGames();
|
||||||
|
long libraryEntries = statsRepository.countLibraryEntries();
|
||||||
|
long gamesPlayed = statsRepository.countGamesPlayed();
|
||||||
|
long playtimeMinutes = statsRepository.sumPlaytimeMinutes();
|
||||||
|
long usersWithLibrary = statsRepository.countUsersWithLibrary();
|
||||||
|
long biggestLibrary = statsRepository.biggestLibrary();
|
||||||
|
long biggestBacklog = statsRepository.biggestBacklog();
|
||||||
|
long avgGames = usersWithLibrary == 0 ? 0L : libraryEntries / usersWithLibrary;
|
||||||
|
long playtimeDays = playtimeMinutes / (60 * 24);
|
||||||
|
|
||||||
|
List<PublicStatsDto.TopGame> topOwned = mapTop(statsRepository.topOwnedGames(TOP_LIMIT));
|
||||||
|
List<PublicStatsDto.TopGame> topPlayed = mapTop(statsRepository.topPlayedGames(TOP_LIMIT));
|
||||||
|
|
||||||
|
return new PublicStatsDto(
|
||||||
|
distinctGames,
|
||||||
|
libraryEntries,
|
||||||
|
gamesPlayed,
|
||||||
|
playtimeMinutes,
|
||||||
|
playtimeDays,
|
||||||
|
usersWithLibrary,
|
||||||
|
avgGames,
|
||||||
|
biggestLibrary,
|
||||||
|
biggestBacklog,
|
||||||
|
topOwned,
|
||||||
|
topPlayed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PublicStatsDto.TopGame> mapTop(List<Map<String, Object>> rows) {
|
||||||
|
return rows.stream()
|
||||||
|
.map(r -> new PublicStatsDto.TopGame(
|
||||||
|
((Number) r.get("appid")).longValue(),
|
||||||
|
(String) r.get("name"),
|
||||||
|
(String) r.get("img_icon_url"),
|
||||||
|
((Number) r.get("value")).longValue()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package core_service.modules.steam_adapter.api.dto.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record SteamPlayerAchievementsResponse(
|
||||||
|
@JsonProperty("playerstats") PlayerStats playerstats
|
||||||
|
) {
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record PlayerStats(
|
||||||
|
@JsonProperty("steamID") String steamId,
|
||||||
|
@JsonProperty("gameName") String gameName,
|
||||||
|
@JsonProperty("achievements") List<Achievement> achievements,
|
||||||
|
@JsonProperty("success") Boolean success,
|
||||||
|
@JsonProperty("error") String error
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Achievement(
|
||||||
|
@JsonProperty("apiname") String apiName,
|
||||||
|
@JsonProperty("achieved") Integer achieved,
|
||||||
|
@JsonProperty("unlocktime") Long unlockTime
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package core_service.modules.steam_adapter.api.dto.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record SteamSchemaForGameResponse(
|
||||||
|
@JsonProperty("game") Game game
|
||||||
|
) {
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Game(
|
||||||
|
@JsonProperty("gameName") String gameName,
|
||||||
|
@JsonProperty("gameVersion") String gameVersion,
|
||||||
|
@JsonProperty("availableGameStats") AvailableGameStats availableGameStats
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record AvailableGameStats(
|
||||||
|
@JsonProperty("achievements") List<Achievement> achievements
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Achievement(
|
||||||
|
@JsonProperty("name") String name,
|
||||||
|
@JsonProperty("displayName") String displayName,
|
||||||
|
@JsonProperty("description") String description,
|
||||||
|
@JsonProperty("hidden") Integer hidden,
|
||||||
|
@JsonProperty("icon") String icon,
|
||||||
|
@JsonProperty("icongray") String iconGray
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package core_service.modules.steam_adapter.service;
|
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.SteamPlayerAchievementsResponse;
|
||||||
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 core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
||||||
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import io.github.resilience4j.retry.annotation.Retry;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -138,4 +141,59 @@ public class SteamApiService {
|
|||||||
|
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-game achievement schema (global, language-aware).
|
||||||
|
* Returns empty when game has no achievements or Steam returns no schema.
|
||||||
|
*/
|
||||||
|
@RateLimiter(name = "steamApi")
|
||||||
|
@Retry(name = "steamApi")
|
||||||
|
public Optional<SteamSchemaForGameResponse.Game> getSchemaForGame(long appid) {
|
||||||
|
try {
|
||||||
|
SteamSchemaForGameResponse response = steamRestClient.get()
|
||||||
|
.uri(uriBuilder -> uriBuilder
|
||||||
|
.path("/ISteamUserStats/GetSchemaForGame/v2/")
|
||||||
|
.queryParam("key", apiKey)
|
||||||
|
.queryParam("appid", appid)
|
||||||
|
.build())
|
||||||
|
.retrieve()
|
||||||
|
.body(SteamSchemaForGameResponse.class);
|
||||||
|
|
||||||
|
if (response != null && response.game() != null) {
|
||||||
|
return Optional.of(response.game());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("GetSchemaForGame failed for appid {}: {}", appid, e.getMessage());
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user achievement state for a single game.
|
||||||
|
* Returns empty when profile/stats are private (HTTP 403) or game has no achievements.
|
||||||
|
*/
|
||||||
|
@RateLimiter(name = "steamApi")
|
||||||
|
@Retry(name = "steamApi")
|
||||||
|
public Optional<SteamPlayerAchievementsResponse.PlayerStats> getPlayerAchievements(String steamId, long appid) {
|
||||||
|
try {
|
||||||
|
SteamPlayerAchievementsResponse response = steamRestClient.get()
|
||||||
|
.uri(uriBuilder -> uriBuilder
|
||||||
|
.path("/ISteamUserStats/GetPlayerAchievements/v1/")
|
||||||
|
.queryParam("key", apiKey)
|
||||||
|
.queryParam("steamid", steamId)
|
||||||
|
.queryParam("appid", appid)
|
||||||
|
.build())
|
||||||
|
.retrieve()
|
||||||
|
.body(SteamPlayerAchievementsResponse.class);
|
||||||
|
|
||||||
|
if (response != null && response.playerstats() != null) {
|
||||||
|
return Optional.of(response.playerstats());
|
||||||
|
}
|
||||||
|
} catch (HttpClientErrorException.Forbidden e) {
|
||||||
|
log.info("Achievements hidden by Steam privacy for steamId={} appid={}", steamId, appid);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("GetPlayerAchievements failed for steamId={} appid={}: {}", steamId, appid, e.getMessage());
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,8 @@ app:
|
|||||||
sync-ttl-hours: 24
|
sync-ttl-hours: 24
|
||||||
force-sync-cooldown-seconds: 60
|
force-sync-cooldown-seconds: 60
|
||||||
running-stale-minutes: 5
|
running-stale-minutes: 5
|
||||||
|
achievement-ttl-hours: 1
|
||||||
|
schema-ttl-days: 30
|
||||||
|
|
||||||
resilience4j:
|
resilience4j:
|
||||||
retry:
|
retry:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Per-game achievement schema (global, shared across users).
|
||||||
|
-- Populated on demand from Steam GetSchemaForGame.
|
||||||
|
CREATE TABLE game_achievement_schema (
|
||||||
|
appid BIGINT NOT NULL,
|
||||||
|
api_name VARCHAR(256) NOT NULL,
|
||||||
|
display_name VARCHAR(512),
|
||||||
|
description VARCHAR(2048),
|
||||||
|
icon_url VARCHAR(512),
|
||||||
|
icon_gray_url VARCHAR(512),
|
||||||
|
hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
PRIMARY KEY (appid, api_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE game_achievement_schema_meta (
|
||||||
|
appid BIGINT PRIMARY KEY,
|
||||||
|
achievements_total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_schema BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-user unlock state per game.
|
||||||
|
CREATE TABLE user_game_achievement (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
appid BIGINT NOT NULL,
|
||||||
|
api_name VARCHAR(256) NOT NULL,
|
||||||
|
unlocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
unlocked_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (user_id, appid, api_name)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_uga_user_app ON user_game_achievement (user_id, appid);
|
||||||
|
|
||||||
|
-- Per-user per-game refresh marker plus visibility flag (Steam may refuse stats).
|
||||||
|
CREATE TABLE user_game_achievement_meta (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
appid BIGINT NOT NULL,
|
||||||
|
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
stats_visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
achievements_unlocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, appid)
|
||||||
|
);
|
||||||
Reference in new issue
Block a user