diff --git a/src/main/java/core_service/CoreServiceApplication.java b/src/main/java/core_service/CoreServiceApplication.java index 1509687..06cae82 100644 --- a/src/main/java/core_service/CoreServiceApplication.java +++ b/src/main/java/core_service/CoreServiceApplication.java @@ -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); } } diff --git a/src/main/java/core_service/configuration/CacheConfig.java b/src/main/java/core_service/configuration/CacheConfig.java index 12ec458..bfad2c0 100644 --- a/src/main/java/core_service/configuration/CacheConfig.java +++ b/src/main/java/core_service/configuration/CacheConfig.java @@ -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; } diff --git a/src/main/java/core_service/configuration/security/SecurityConfig.java b/src/main/java/core_service/configuration/security/SecurityConfig.java index 75ab400..19bc823 100644 --- a/src/main/java/core_service/configuration/security/SecurityConfig.java +++ b/src/main/java/core_service/configuration/security/SecurityConfig.java @@ -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; diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 2d7159e..eeea7df 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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) + .>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 getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { return queryService.getSyncStatus(principal.getIdAsLong()) diff --git a/src/main/java/core_service/modules/library/api/dto/AchievementDto.java b/src/main/java/core_service/modules/library/api/dto/AchievementDto.java new file mode 100644 index 0000000..42455c1 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AchievementDto.java @@ -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 +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java new file mode 100644 index 0000000..d36b905 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -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 achievements +) {} diff --git a/src/main/java/core_service/modules/library/domain/GameAchievementSchema.java b/src/main/java/core_service/modules/library/domain/GameAchievementSchema.java new file mode 100644 index 0000000..44fb70d --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameAchievementSchema.java @@ -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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/GameAchievementSchemaId.java b/src/main/java/core_service/modules/library/domain/GameAchievementSchemaId.java new file mode 100644 index 0000000..a3f840e --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameAchievementSchemaId.java @@ -0,0 +1,19 @@ +package core_service.modules.library.domain; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class GameAchievementSchemaId implements Serializable { + private Long appid; + private String apiName; +} diff --git a/src/main/java/core_service/modules/library/domain/GameAchievementSchemaMeta.java b/src/main/java/core_service/modules/library/domain/GameAchievementSchemaMeta.java new file mode 100644 index 0000000..b0693ef --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameAchievementSchemaMeta.java @@ -0,0 +1,40 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Entity +@Table(name = "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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/UserGameAchievement.java b/src/main/java/core_service/modules/library/domain/UserGameAchievement.java new file mode 100644 index 0000000..07d212b --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/UserGameAchievement.java @@ -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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/UserGameAchievementId.java b/src/main/java/core_service/modules/library/domain/UserGameAchievementId.java new file mode 100644 index 0000000..4cf4f8f --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/UserGameAchievementId.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/UserGameAchievementMeta.java b/src/main/java/core_service/modules/library/domain/UserGameAchievementMeta.java new file mode 100644 index 0000000..badfd38 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/UserGameAchievementMeta.java @@ -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; + } +} diff --git a/src/main/java/core_service/modules/library/repo/GameAchievementSchemaMetaRepository.java b/src/main/java/core_service/modules/library/repo/GameAchievementSchemaMetaRepository.java new file mode 100644 index 0000000..d7df175 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameAchievementSchemaMetaRepository.java @@ -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 { +} diff --git a/src/main/java/core_service/modules/library/repo/GameAchievementSchemaRepository.java b/src/main/java/core_service/modules/library/repo/GameAchievementSchemaRepository.java new file mode 100644 index 0000000..f73be26 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameAchievementSchemaRepository.java @@ -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 { + + List findByAppid(Long appid); + + void deleteByAppid(Long appid); +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameAchievementMetaRepository.java b/src/main/java/core_service/modules/library/repo/UserGameAchievementMetaRepository.java new file mode 100644 index 0000000..4f6f88c --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/UserGameAchievementMetaRepository.java @@ -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 { + + Optional findByUserIdAndAppid(Long userId, Long appid); +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameAchievementRepository.java b/src/main/java/core_service/modules/library/repo/UserGameAchievementRepository.java new file mode 100644 index 0000000..e088cbe --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/UserGameAchievementRepository.java @@ -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 { + + List findByUserIdAndAppid(Long userId, Long appid); + + void deleteByUserIdAndAppid(Long userId, Long appid); +} diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java new file mode 100644 index 0000000..19ee93c --- /dev/null +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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 getGameDetail(Long userId, String steamId, Long appid) { + Optional 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 schemaList = schemaRepository.findByAppid(appid); + Map userMap = new HashMap<>(); + for (UserGameAchievement uga : userAchievementRepository.findByUserIdAndAppid(userId, appid)) { + userMap.put(uga.getApiName(), uga); + } + + List 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 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 existing = schemaMetaRepository.findById(appid); + Instant now = Instant.now(); + if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofDays(schemaTtlDays)))) { + return; + } + Optional 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 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 existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid); + Instant now = Instant.now(); + if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofHours(achievementTtlHours)))) { + return; + } + + Optional 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 list = response.get().achievements(); + userAchievementRepository.deleteByUserIdAndAppid(userId, appid); + int unlockedCount = 0; + if (list != null) { + List 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)); + } +} diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 62dbc4c..5402798 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -31,10 +31,6 @@ public class LibraryQueryService { return userGameRepository.countByUserIdAndRemovedAtIsNull(userId); } - public List listOwnedGames(Long userId) { - return userGameRepository.findByUserIdAndRemovedAtIsNull(userId); - } - public Page listOwnedGames(Long userId, Pageable pageable) { Page page = userGameRepository.findByUserIdAndRemovedAtIsNull(userId, pageable); if (page.isEmpty()) { diff --git a/src/main/java/core_service/modules/stats/api/PublicStatsController.java b/src/main/java/core_service/modules/stats/api/PublicStatsController.java new file mode 100644 index 0000000..b12fe49 --- /dev/null +++ b/src/main/java/core_service/modules/stats/api/PublicStatsController.java @@ -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 getPublicStats() { + return ResponseEntity.ok(publicStatsService.getPublicStats()); + } +} diff --git a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java new file mode 100644 index 0000000..b86dd7f --- /dev/null +++ b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java @@ -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 topOwnedGames, + List topPlayedGames +) { + public record TopGame(Long appid, String name, String imgIconUrl, long value) {} +} diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java new file mode 100644 index 0000000..6852b98 --- /dev/null +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -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> 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> 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); + } +} diff --git a/src/main/java/core_service/modules/stats/service/PublicStatsService.java b/src/main/java/core_service/modules/stats/service/PublicStatsService.java new file mode 100644 index 0000000..9639281 --- /dev/null +++ b/src/main/java/core_service/modules/stats/service/PublicStatsService.java @@ -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 topOwned = mapTop(statsRepository.topOwnedGames(TOP_LIMIT)); + List topPlayed = mapTop(statsRepository.topPlayedGames(TOP_LIMIT)); + + return new PublicStatsDto( + distinctGames, + libraryEntries, + gamesPlayed, + playtimeMinutes, + playtimeDays, + usersWithLibrary, + avgGames, + biggestLibrary, + biggestBacklog, + topOwned, + topPlayed + ); + } + + private List mapTop(List> 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(); + } +} diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamPlayerAchievementsResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamPlayerAchievementsResponse.java new file mode 100644 index 0000000..6883923 --- /dev/null +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamPlayerAchievementsResponse.java @@ -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 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 + ) {} +} diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamSchemaForGameResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamSchemaForGameResponse.java new file mode 100644 index 0000000..b8ee3d3 --- /dev/null +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamSchemaForGameResponse.java @@ -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 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 + ) {} +} diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java index 122ce36..008eb2e 100644 --- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java +++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java @@ -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 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 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(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 22739fd..dd33a2d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/db/migration/V2__game_achievements.sql b/src/main/resources/db/migration/V2__game_achievements.sql new file mode 100644 index 0000000..b07e291 --- /dev/null +++ b/src/main/resources/db/migration/V2__game_achievements.sql @@ -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) +);