Public Access
Added more insight to the games of a user
This commit is contained in:
1 parent
9d8bb674d6
commit
fb3a5140b8
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;
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
+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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
Reference in new issue
Block a user