Added more insight to the games of a user

This commit is contained in:
Andreas Dahm committed 2026-05-04 15:14:25 +02:00
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
public class CoreServiceApplication {
public static void main(String[] args) {
static void main(String[] args) {
SpringApplication.run(CoreServiceApplication.class, args);
}
}
@@ -15,9 +15,9 @@ public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000));
return cacheManager;
}
@@ -29,7 +29,8 @@ public class SecurityConfig {
"/actuator/health",
"/actuator/prometheus",
"/error",
"/api/steam/test/**"
"/api/steam/test/**",
"/api/stats/public"
};
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.PageDto;
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.LibrarySyncService;
import core_service.modules.library.service.SyncCooldownException;
@@ -15,6 +16,7 @@ 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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -34,6 +36,7 @@ public class LibraryController {
private final LibrarySyncService syncService;
private final LibraryQueryService queryService;
private final GameDetailService gameDetailService;
@GetMapping("/games-count")
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")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
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;
}
}
@@ -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);
}
@@ -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);
}
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()) {
@@ -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();
}
}
@@ -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
) {}
}
@@ -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;
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.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.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
@@ -138,4 +141,59 @@ public class SteamApiService {
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();
}
}
+2
View File
@@ -48,6 +48,8 @@ app:
sync-ttl-hours: 24
force-sync-cooldown-seconds: 60
running-stale-minutes: 5
achievement-ttl-hours: 1
schema-ttl-days: 30
resilience4j:
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)
);