Compare commits

...
29 Commits
Author SHA1 Message Date
lobo 0a5186782f test: generate the JWT test keys, drop application-dev.yml
Build, Test & Push Core Service / docker (push) Successful in 1m20s
Build, Test & Push Core Service / quality-check (push) Successful in 41s
The dev config held real keys. Local dev config now lives outside the repo; integration tests create a fresh Ed25519 keypair per run.
2026-09-25 08:37:55 +02:00
lobo 9ad4c4b1bd docs: rewrite the readme for the public showcase
Also ignore local secrets.
2026-09-25 08:22:56 +02:00
lobo ba75c00301 feat(library): Steam name and avatar for the header 2026-09-24 15:27:33 +02:00
lobo 00a7991380 feat(library): time to beat from IGDB 2026-09-24 14:40:41 +02:00
lobo 2f2f8a2918 feat(profile): public profile data for a ticket from Auth
GET /api/public/profiles/library with X-Profile-Ticket returns totals, status
counts, playing and finished games with rating; never notes, priority or the
Steam ID. Login filters reject the ticket, and Core keeps no copy of the
privacy flag.
2026-09-24 14:18:32 +02:00
lobo ea1e1e9aec feat(library): sync the Steam wishlist with store prices
Each sync fetches the public wishlist and the store name, price and discount
(batches of 100) and replaces the user's wishlist_game rows, without games
the user owns. A Steam error keeps the old list, a failed store batch keeps
the last known price, and a Steam ID change drops the old account's list.
GET /api/library/wishlist returns it newest first.
2026-09-24 13:58:44 +02:00
lobo ba1f1e6fe1 feat(library): playtime history per day and a nightly sync
Each sync records the growth of playtime per game and day (Europe/Berlin).
The first successful sync of a Steam account is only the baseline, tracked in
user_steam_sync.history_steam_id, so a new or failed account never turns its
whole playtime into one day. A nightly sync at 23:55 runs the recently used
accounts one after the other. GET /api/library/activity returns minutes per
day and the games of Steam's last two weeks.
2026-09-24 13:41:37 +02:00
lobo afa2a7ea75 feat(library): sort by rating and priority with a stable order
Games without a rating or priority come last in both directions. Priority
sorts HIGH, MEDIUM, LOW instead of by enum name, and appid breaks every tie,
so pages no longer repeat or skip games with equal values.
2026-09-24 13:17:12 +02:00
lobo 0482fb0c88 feat(library): summary endpoint with status counts and a backlog pick
GET /api/library/summary returns the game count per status, the never-started
count and a random backlog game. exclude=<appid> makes a new pick differ from
the current one.
2026-09-24 11:22:29 +02:00
lobo 474a5dd570 test: share one Postgres container across test classes
The per-class container was stopped under the Spring context that the next
test class reused, so a second integration test class failed with
"Connection is not available".
2026-09-24 11:22:29 +02:00
lobo 3351ef75f0 fix(library): keep backlog edits made during a sync, reject fractional ratings
UserGame now uses @DynamicUpdate, so the sync flush writes only the columns it
changed and no longer overwrites a backlog update committed meanwhile.
2026-09-24 11:04:02 +02:00
lobo 0ac93394d9 feat(library): backlog status, rating, notes and priority per game
New games start as BACKLOG, or PLAYING when they have playtime. The sync
never changes a status later. PUT /api/library/games/{appid}/backlog sets
all four fields; /api/library/games filters by status and name (q).
2026-09-24 10:51:17 +02:00
lobo b46e2badbd fix(security): close the Steam test endpoint, /internal and Prometheus to the public
/api/steam/test/{steamId} let anyone query any Steam profile with our API key. /internal/** had no controller but was open for any future one. Actuator now exposes only health, without details. The management block was also indented under endpoints, so show-details never applied.
2026-09-24 10:24:34 +02:00
lobo d96e682511 chore(deps): update Spring Boot to 4.1.1, ArchUnit to 1.5.0, resilience4j to 2.4.0
resilience4j-spring-boot3 does not match Spring Boot 4, so switch to resilience4j-spring-boot4.
2026-09-24 08:56:45 +02:00
lobo 50f5f26157 fix(library): sync again after a Steam ID change and keep library on empty result
An empty Steam answer (error, private profile, wrong ID) was stored as OK and
soft-deleted the whole library. It is now FAILED, keeps the library and is
retried after 15 minutes (app.library.failed-retry-minutes).

The automatic sync now also runs when the Steam ID in the token differs from
the stored one, and removes the games of the previous Steam account.
2026-09-24 08:56:45 +02:00
Andreas Dahm 8de138487e Small fix 2026-05-12 09:50:47 +02:00
lobo aaa3345651 Merge pull request 'Changed from JWT token to public/privat key' (#1) from feat/jwt-eddsa-verify into main
Reviewed-on: #1
2026-05-12 08:58:43 +02:00
Andreas Dahm dbea8922d5 Changed from JWT token to public/privat key 2026-05-12 08:57:12 +02:00
Andreas DahmandClaude Opus 4.7 b7efe27a54 Add Core-owned product roadmap to README
Captures the Core slices of the cross-service product roadmap: per-game
tracking schema (status, rating, notes, completion date), picker
endpoint, HLTB integration, achievement aggregation, goals + digest
data, wishlist + sale alerts, library enrichment, and public profile
data. Cross-links to the umbrella roadmap in ToBePlayed-Deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:51:29 +02:00
Andreas Dahm 32339e2ff5 Changed README.md 2026-05-11 14:40:29 +02:00
Andreas Dahm 1eb445e7a0 Added tests 2026-05-11 14:31:38 +02:00
Andreas Dahm 6b91463c4a Added more insight to the games of a user 2026-05-04 15:14:25 +02:00
Andreas Dahm 2f3507b57b Added new endpoints to sync the list of games from steam if a user logged in. 2026-05-04 11:47:26 +02:00
Andreas Dahm ac97442312 Added dev impersonation and changed api a little bit 2026-04-29 08:50:02 +02:00
Andreas Dahm 1b08e7902c Try to fetch steam game count if requested and cache it 2026-04-28 15:43:02 +02:00
lobo be9b70c541 Add owned-games DTO and full profile support
Introduce SteamOwnedGamesResponse and SteamProfileAndGamesResponse DTOs and extend SteamApiService with getOwnedGames(...) and getFullPlayerProfile(...) to fetch and combine player summary and owned games. Update SteamTestController to return the combined profile/games response. Remove redundant Steam service URL entries from application.yml. Add SteamApiServiceTest to cover player summary, owned games (including null fields), and the combined profile flow; service falls back to empty lists on errors.
2026-04-19 15:15:15 +02:00
lobo 1dc2efba01 Refactor Steam adapter packages and update docs
Move steam_adapter sources from domain.* to modules.* and rename controller package to api. Update package declarations and imports across SteamTestController, SteamPlayerSummaryResponse, SteamClientConfig, and SteamApiService. Update README to re-label Controller -> API layer and Domain -> Business layer and adjust wording about layer boundaries. Also normalize file endings/newlines in several Java files. No functional logic changes were made.
2026-04-19 12:49:32 +02:00
lobo a1b11882de Add Steam API client, service, controller
Introduce Steam integration: add SteamClientConfig (RestClient bean), SteamApiService (calls GetPlayerSummaries and returns a Player DTO), SteamPlayerSummaryResponse DTO, and SteamTestController (/api/steam/test/{steamId}) for quick testing. Update SecurityConfig to permit the test endpoint and adjust the SecurityFilterChain bean signature. Add Steam API settings: base-url in application.yml and a dev API key in application-dev.yml. Includes basic error logging and optional response handling when no player is found.
2026-04-19 11:38:31 +02:00
lobo 5881dd0613 Initial project setup: CI, Docker & configs
Add project bootstrap for build, CI/CD, and local development. Introduces Maven Wrapper (mvnw, mvnw.cmd, .mvn/wrapper), multi-stage Dockerfile, docker-compose files (compose.yaml, docker-compose.dev.yml), .env, banner and README. Adds Gitea Actions workflow to build, test and push container images. Update application.yml (port -> 8090, virtual threads, API versioning, management/actuator/prometheus settings, Steam API config) and add application-dev.yml for local overrides. Extend pom.xml with caching, observability (actuator, micrometer/prometheus), testing/dev dependencies (testcontainers, devtools, additional spring test modules) to support CI and local testing.
2026-04-19 10:44:29 +02:00
122 changed files with 6944 additions and 42 deletions

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
name: Build, Test & Push Core Service
# Trigger the workflow
on:
push:
branches:
- main
jobs:
# ------------------------------------------------------------------
# JOB 1: Code integrity and quality gates (CI)
# ------------------------------------------------------------------
quality-check:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Java 25
uses: actions/setup-java@v4
with:
java-version: '25'
distribution: 'temurin'
# Automatically cache Maven dependencies to speed up builds
cache: 'maven'
- name: Make Maven Wrapper executable
# This fixes the "Permission denied" error on Linux runners
run: chmod +x mvnw
- name: Build and Test with Maven Wrapper
# Uses the wrapper to guarantee the correct Maven version
run: ./mvnw clean verify
# ------------------------------------------------------------------
# JOB 2: Docker Build & Push (CD)
# Runs only if 'quality-check' is successful and we are on branch main
# ------------------------------------------------------------------
docker:
needs: quality-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Prepare tag
id: prep
shell: bash
run: |
# Sanitize branch name for valid Docker tags
BRANCH="${GITHUB_REF_NAME}"
BRANCH="${BRANCH//\//-}"
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.andreas-dahm.eu
# Use your Gitea Secrets here
username: ${{ github.repository_owner }}
password: ${{ secrets.TOKEN_GITEA }}
- name: Build & Push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
git.andreas-dahm.eu/lobo_corp/tbp-core-service:${{ steps.prep.outputs.branch }}
git.andreas-dahm.eu/lobo_corp/tbp-core-service:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
git.andreas-dahm.eu/lobo_corp/tbp-core-service:latest
+7
View File
@@ -31,3 +31,10 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
### Local secrets ###
.env
### Local dev config, never committed ###
application-dev.yml
application-dev.yaml
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
+35
View File
@@ -0,0 +1,35 @@
# ------------------------------------------------------------------
# STAGE 1: Build the application using Maven Wrapper
# ------------------------------------------------------------------
FROM eclipse-temurin:25-jdk-alpine AS builder
WORKDIR /app
# Copy the Maven wrapper and pom.xml first to cache dependencies
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
# Make the wrapper executable (just in case) and download dependencies
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline
# Copy the actual source code and build the application (skip tests in Docker,
# as they will be run by the Gitea Action before the build)
COPY src ./src
RUN ./mvnw clean package -DskipTests
# ------------------------------------------------------------------
# STAGE 2: Create the minimal runtime image
# ------------------------------------------------------------------
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
# Expose the port your Spring Boot app runs on
EXPOSE 8090
# Copy the compiled jar file from the builder stage
COPY --from=builder /app/target/*.jar app.jar
# Run the application
ENTRYPOINT ["java", "-jar", "app.jar"]
+10
View File
@@ -0,0 +1,10 @@
services:
postgres:
image: postgres:17-alpine
container_name: tbp_core_db_local
ports:
- "5433:5432"
environment:
- POSTGRES_DB=tbp_core
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=local_secret_password
+22
View File
@@ -0,0 +1,22 @@
# docker-compose.dev.yml
services:
# ─────────────────────────────────────────────
# LOCAL DATABASE FOR DEVELOPMENT
# ─────────────────────────────────────────────
tbp-db-local:
image: postgres:17-alpine
container_name: tbp_db_local
restart: unless-stopped
ports:
# Map the container port 5432 to your local machine port 5432
- "5432:5432"
environment:
- POSTGRES_DB=tbp_core
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
# Use a local volume to persist data across container restarts
- tbp_local_db_data:/var/lib/postgresql/data
volumes:
tbp_local_db_data:
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+58 -2
View File
@@ -5,7 +5,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0-M4</version> <version>4.1.1</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>eu.andreasdahm.tbp</groupId> <groupId>eu.andreasdahm.tbp</groupId>
@@ -16,7 +16,8 @@
<java.version>25</java.version> <java.version>25</java.version>
<jwt.version>0.13.0</jwt.version> <jwt.version>0.13.0</jwt.version>
<testcontainers.version>1.21.4</testcontainers.version> <testcontainers.version>1.21.4</testcontainers.version>
<archunit.version>1.4.1</archunit.version> <archunit.version>1.5.0</archunit.version>
<resilience4j.version>2.4.0</resilience4j.version>
</properties> </properties>
<dependencies> <dependencies>
<!-- Spring --> <!-- Spring -->
@@ -40,10 +41,17 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId> <artifactId>spring-boot-starter-cache</artifactId>
</dependency> </dependency>
<!-- Rest Caching -->
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
</dependency> </dependency>
<!-- Resilience: retry + rate limiter for outbound Steam calls -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot4</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<!-- JWT --> <!-- JWT -->
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
@@ -75,6 +83,8 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId> <artifactId>spring-boot-starter-flyway</artifactId>
</dependency> </dependency>
<!-- Observability -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
@@ -84,6 +94,14 @@
<artifactId>micrometer-registry-prometheus</artifactId> <artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
@@ -99,11 +117,48 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId> <artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
<version>${testcontainers.version}</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>com.tngtech.archunit</groupId> <groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId> <artifactId>archunit-junit5</artifactId>
@@ -111,6 +166,7 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
+113
View File
@@ -0,0 +1,113 @@
# To Be Played – Core Service
Business service of **To Be Played**, a Steam backlog tracker. It syncs the user's Steam library, stores the backlog
(status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB.
To Be Played is split into three repositories:
| Repository | Role |
|---|---|
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app |
## Features
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
dashboard banner.
- **Wishlist**: synced from Steam with store price and discount.
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
feature is off and the rest works.
- **Public profile data**: totals, status counts, games in progress and finished games with rating. Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
## Authentication
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with the public key only (`JWT_PUBLIC_KEY`) and
reads `userId` and `steamId` from the claims. A token with a `typ` claim (for example a profile ticket) is rejected
on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the
`X-Profile-Ticket` header.
## API
| Method | Path | Auth |
|---|---|---|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
| `POST` | `/api/library/sync` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public |
## Tech stack
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
- PostgreSQL 17, Flyway migrations
- Resilience4j: retry and rate limiter for Steam, rate limiter for IGDB
- JJWT with Ed25519 public key
- Spring Boot Actuator + Micrometer (Prometheus)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image
## Architecture
A modular monolith. Each module under `core_service.modules` is a vertical slice that could move to its own
service later:
| Module | Content |
|---|---|
| `library` | Sync, backlog, playtime history, wishlist, public profile data |
| `stats` | Public numbers for the landing page |
| `steam_adapter` | Steam Web API client |
| `igdb_adapter` | IGDB client (Twitch OAuth) |
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(Spring Data repositories). ArchUnit enforces cycle freedom, controller-to-repository direction, naming and module
isolation in `src/test/java/core_service/architecture/ArchitectureTest.java`.
## Run locally
Needs Java 25 and Docker.
```bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
```
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
service listens on `http://localhost:8090`. Health: `/actuator/health`, metrics: `/actuator/prometheus`.
In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a
running Auth service.
Configuration comes from environment variables:
| Variable | Purpose |
|---|---|
| `JWT_PUBLIC_KEY` | Auth's Ed25519 public key, Base64 of the X.509 DER bytes |
| `STEAM_API_KEY` | Steam Web API key |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
| `DB_PASSWORD` | Local PostgreSQL password |
## Tests
```bash
./mvnw clean verify
```
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
## Roadmap
- "What to play next" picker: time budget and mood on top of the current random suggestion.
- Achievement summary per game and library-wide, "closest to 100 %".
- Sale alerts for wishlist games.
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
- Fetch the public key from a JWKS endpoint in Auth instead of an environment variable.
## License
All rights reserved. The source is public for reading only.
@@ -6,7 +6,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication @SpringBootApplication
public class CoreServiceApplication { public class CoreServiceApplication {
public static void main(String[] args) { static void main(String[] args) {
SpringApplication.run(CoreServiceApplication.class, args); SpringApplication.run(CoreServiceApplication.class, args);
} }
} }
@@ -0,0 +1,9 @@
package core_service.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}
@@ -0,0 +1,26 @@
package core_service.configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import java.util.concurrent.TimeUnit;
@Configuration
// Outside the resilience4j aspects, so a cache hit takes no Steam rate limiter permit.
@EnableCaching(order = Ordered.HIGHEST_PRECEDENCE)
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000));
return cacheManager;
}
}
@@ -0,0 +1,9 @@
package core_service.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
@@ -0,0 +1,87 @@
package core_service.configuration.security;
import core_service.shared.security.UserPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
/**
* Dev profile authentication filter.
* - If Authorization: Bearer ... is present and valid → builds principal from real JWT (so different users are isolated).
* - If no Authorization header → injects dummy principal so unauthenticated dev tools still work.
*/
@Component("jwtAuthenticationFilter")
@Profile("dev")
@RequiredArgsConstructor
@Slf4j
public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
if (SecurityContextHolder.getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}
UserPrincipal principal = principalFromJwt(request).orElseGet(this::dummyPrincipal);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal,
null,
Collections.emptyList()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
private java.util.Optional<UserPrincipal> principalFromJwt(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return java.util.Optional.empty();
}
String jwt = authHeader.substring(7);
if (!jwtService.isTokenValid(jwt)) {
log.debug("Dev mode: invalid JWT, falling back to dummy principal");
return java.util.Optional.empty();
}
try {
String email = jwtService.extractEmail(jwt);
String userId = jwtService.extractUserId(jwt);
Long steamId = jwtService.extractSteamId(jwt);
if (email == null || userId == null) {
return java.util.Optional.empty();
}
return java.util.Optional.of(new UserPrincipal(userId, email, steamId));
} catch (Exception e) {
log.warn("Dev mode: JWT parsing failed: {}", e.getMessage());
return java.util.Optional.empty();
}
}
private UserPrincipal dummyPrincipal() {
log.trace("Dev mode: no JWT present, injecting dummy principal");
return new UserPrincipal("1", "dev@example.com", 76561198000000001L);
}
}
@@ -8,6 +8,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
@@ -17,7 +18,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
@Component @Component("jwtAuthenticationFilter")
@Profile("!dev")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
@@ -47,10 +49,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
try { try {
final String userEmail = jwtService.extractEmail(jwt); final String userEmail = jwtService.extractEmail(jwt);
final String userId = jwtService.extractUserId(jwt); final String userId = jwtService.extractUserId(jwt);
final Long steamId = jwtService.extractSteamId(jwt);
if (userEmail != null && userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { if (userEmail != null && userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserPrincipal principal = new UserPrincipal(userId, userEmail); UserPrincipal principal = new UserPrincipal(userId, userEmail, steamId);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal, principal,
null, null,
@@ -66,3 +69,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
} }
@@ -11,5 +11,6 @@ import org.springframework.context.annotation.Configuration;
@ConfigurationProperties(prefix = "security.jwt") @ConfigurationProperties(prefix = "security.jwt")
public class JwtConfig { public class JwtConfig {
private String secret; /** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */
private String publicKey;
} }
@@ -1,25 +1,42 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.ProfileTicketVerifier;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.crypto.SecretKey; import java.security.KeyFactory;
import java.nio.charset.StandardCharsets; import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Optional;
/**
* Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key.
*/
@Service @Service
public class JwtService { public class JwtService implements ProfileTicketVerifier {
private static final String ED25519 = "Ed25519";
// Only non-login tokens carry this claim; isTokenValid rejects them.
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
private final JwtConfig jwtConfig; private final JwtConfig jwtConfig;
private PublicKey publicKey;
public JwtService(JwtConfig jwtConfig) { public JwtService(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig; this.jwtConfig = jwtConfig;
} }
@PostConstruct
void init() {
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
}
public String extractEmail(String token) { public String extractEmail(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(getSigningKey()) .verifyWith(publicKey)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(token)
.getPayload() .getPayload()
@@ -28,28 +45,61 @@ public class JwtService {
public String extractUserId(String token) { public String extractUserId(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(getSigningKey()) .verifyWith(publicKey)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(token)
.getPayload() .getPayload()
.get("userId", String.class); .get("userId", String.class);
} }
public Long extractSteamId(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("steamId", Long.class);
}
/**
* True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login.
*/
public boolean isTokenValid(String token) { public boolean isTokenValid(String token) {
try { try {
Jwts.parser() var claims = Jwts.parser()
.verifyWith(getSigningKey()) .verifyWith(publicKey)
.build() .build()
.parseSignedClaims(token); .parseSignedClaims(token)
return true; .getPayload();
return claims.get(TYPE_CLAIM) == null;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }
} }
private SecretKey getSigningKey() { @Override
String secretKey = jwtConfig.getSecret(); public Optional<Long> verifyProfileTicket(String ticket) {
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); try {
return Keys.hmacShaKeyFor(keyBytes); var claims = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(ticket)
.getPayload();
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
return Optional.empty();
}
return Optional.of(Long.parseLong(claims.get("profileUserId", String.class)));
} catch (Exception e) {
return Optional.empty();
}
}
private static PublicKey decodePublicKey(String base64X509Der) {
try {
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
return KeyFactory.getInstance(ED25519).generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
}
} }
} }
@@ -1,6 +1,7 @@
package core_service.configuration.security; package core_service.configuration.security;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -14,23 +15,27 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private static final String[] WHITE_LIST_URLS = { private static final String[] WHITE_LIST_URLS = {
"/internal/**", // Allow internal service communication "/actuator/health", // Docker health check; details are off
"/actuator/health", "/error",
"/actuator/prometheus", "/api/stats/public",
"/error" "/api/public/**" // Public profiles; PublicProfileController checks the ticket
}; };
private final JwtAuthenticationFilter jwtAuthFilter; private final OncePerRequestFilter jwtAuthFilter;
public SecurityConfig(@Qualifier("jwtAuthenticationFilter") OncePerRequestFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Value("${app.cors.allowed-origins}") @Value("${app.cors.allowed-origins}")
private String[] allowedOrigins; private String[] allowedOrigins;
@@ -49,7 +54,7 @@ public class SecurityConfig {
} }
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
@@ -0,0 +1,43 @@
package core_service.modules.igdb_adapter.api.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* The parts of the Twitch token and IGDB v4 answers the adapter reads.
*/
public final class IgdbResponses {
private IgdbResponses() {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Token(
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Long expiresIn
) {}
/**
* @param game the IGDB game id
* @param uid the id on the other service, here the Steam appid as a string
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExternalGame(
@JsonProperty("game") Long game,
@JsonProperty("uid") String uid
) {}
/**
* Times in seconds; each can be missing.
*
* @param count how many players submitted a time
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TimeToBeat(
@JsonProperty("game_id") Long gameId,
@JsonProperty("hastily") Integer hastily,
@JsonProperty("normally") Integer normally,
@JsonProperty("completely") Integer completely,
@JsonProperty("count") Integer count
) {}
}
@@ -0,0 +1,20 @@
package core_service.modules.igdb_adapter.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class IgdbClientConfig {
@Bean
public RestClient igdbRestClient(@Value("${app.igdb.base-url}") String baseUrl) {
return RestClient.builder().baseUrl(baseUrl).build();
}
@Bean
public RestClient twitchRestClient(@Value("${app.igdb.token-base-url}") String tokenBaseUrl) {
return RestClient.builder().baseUrl(tokenBaseUrl).build();
}
}
@@ -0,0 +1,176 @@
package core_service.modules.igdb_adapter.service;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Time to beat from IGDB (api-docs.igdb.com), found by Steam appid. Needs a Twitch developer app; without its
* client id and secret the adapter is off and never calls out.
*/
@Service
@Slf4j
public class IgdbApiService {
/**
* @param hastilyMinutes to the credits; each value is null when IGDB has no data for it
*/
public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {}
// IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row.
private static final int BATCH = 250;
private static final int LIMIT = 500;
private static final long STEAM_SOURCE = 1;
private static final Duration TOKEN_RENEW_BEFORE = Duration.ofHours(1);
private final RestClient igdbRestClient;
private final RestClient twitchRestClient;
private final String clientId;
private final String clientSecret;
// Per IGDB request, not per call: one call makes two requests per batch.
private final RateLimiter rateLimiter;
private String accessToken;
private Instant accessTokenRenewAt = Instant.EPOCH;
public IgdbApiService(@Qualifier("igdbRestClient") RestClient igdbRestClient,
@Qualifier("twitchRestClient") RestClient twitchRestClient,
@Value("${app.igdb.client-id:}") String clientId,
@Value("${app.igdb.client-secret:}") String clientSecret,
RateLimiterRegistry rateLimiterRegistry) {
this.igdbRestClient = igdbRestClient;
this.twitchRestClient = twitchRestClient;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.rateLimiter = rateLimiterRegistry.rateLimiter("igdbApi");
}
public boolean isEnabled() {
return clientId != null && !clientId.isBlank() && clientSecret != null && !clientSecret.isBlank();
}
/**
* @return the times of the apps IGDB knows, apps without data are missing; empty when the adapter is off or a
* request failed, so the caller does not store "unknown" for apps it could not ask about
*/
public Optional<Map<Long, TimeToBeat>> getTimeToBeat(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, TimeToBeat> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
result.putAll(getBatch(appids.subList(from, Math.min(from + BATCH, appids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for time to beat", e);
return Optional.empty();
}
}
private Map<Long, TimeToBeat> getBatch(List<Long> steamAppids) {
String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(","));
IgdbResponses.ExternalGame[] externals = query("/external_games",
"fields game,uid; where external_game_source = " + STEAM_SOURCE + " & uid = (" + uids + "); limit " + LIMIT + ";",
IgdbResponses.ExternalGame[].class);
// Editions and re-releases: one IGDB game can have several Steam apps.
Map<Long, List<Long>> steamByIgdb = new HashMap<>();
for (IgdbResponses.ExternalGame external : externals) {
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) {
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
}
}
if (steamByIgdb.isEmpty()) {
return Map.of();
}
String gameIds = steamByIgdb.keySet().stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats",
"fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.TimeToBeat[].class);
// A Steam app linked to several IGDB games takes the time most players submitted.
Map<Long, TimeToBeat> result = new HashMap<>();
Arrays.stream(times)
.filter(time -> steamByIgdb.containsKey(time.gameId()))
.sorted(Comparator.comparing((IgdbResponses.TimeToBeat time) -> time.count() != null ? time.count() : 0).reversed())
.forEach(time -> steamByIgdb.get(time.gameId()).forEach(appid -> result.putIfAbsent(appid,
new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely())))));
return result;
}
private <T> T query(String path, String body, Class<T> type) {
String token = token();
RateLimiter.waitForPermission(rateLimiter);
T answer;
try {
answer = igdbRestClient.post()
.uri(path)
.header("Client-ID", clientId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.TEXT_PLAIN)
.accept(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(type);
} catch (HttpClientErrorException.Unauthorized e) {
// Revoked, e.g. after a secret reset. The next call asks for a new token.
dropToken(token);
throw e;
}
if (answer == null) {
throw new IllegalStateException("IGDB " + path + " answered without a body");
}
return answer;
}
private synchronized String token() {
if (accessToken == null || Instant.now().isAfter(accessTokenRenewAt)) {
IgdbResponses.Token token = twitchRestClient.post()
.uri(builder -> builder.path("/oauth2/token")
.queryParam("client_id", clientId)
.queryParam("client_secret", clientSecret)
.queryParam("grant_type", "client_credentials")
.build())
.retrieve()
.body(IgdbResponses.Token.class);
if (token == null || token.accessToken() == null) {
throw new IllegalStateException("Twitch answered without an access token");
}
long expiresIn = token.expiresIn() != null ? token.expiresIn() : 0;
accessToken = token.accessToken();
accessTokenRenewAt = Instant.now().plusSeconds(expiresIn).minus(TOKEN_RENEW_BEFORE);
}
return accessToken;
}
private synchronized void dropToken(String token) {
if (token.equals(accessToken)) {
accessToken = null;
}
}
private static Integer minutes(Integer seconds) {
return seconds != null && seconds > 0 ? Math.round(seconds / 60f) : null;
}
}
@@ -0,0 +1,177 @@
package core_service.modules.library.api;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PageDto;
import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.service.BacklogService;
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.SteamProfileService;
import core_service.modules.library.service.SyncCooldownException;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/library")
@RequiredArgsConstructor
public class LibraryController {
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
private static final int DEFAULT_SIZE = 20;
private static final int MIN_ACTIVITY_DAYS = 7;
private static final int MAX_ACTIVITY_DAYS = 90;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
private final LibrarySyncService syncService;
private final LibraryQueryService queryService;
private final GameDetailService gameDetailService;
private final BacklogService backlogService;
private final SteamProfileService steamProfileService;
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) {
return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false));
}
Long userId = principal.getIdAsLong();
String steamId = String.valueOf(principal.steamId());
syncService.syncIfStale(userId, steamId);
long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true));
}
@GetMapping("/games")
public ResponseEntity<PageDto<GameDto>> getGames(
@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "playtimeMinutes,desc") String sort,
@RequestParam(required = false) GameStatus status,
@RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxMinutes
) {
if (principal.steamId() == null) {
return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0));
}
Long userId = principal.getIdAsLong();
String steamId = String.valueOf(principal.steamId());
syncService.syncIfStale(userId, steamId);
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
Sort safeSort = parseSort(sort);
var dtoPage = queryService.listOwnedGames(userId, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort));
return ResponseEntity.ok(PageDto.of(dtoPage));
}
private Sort parseSort(String sort) {
Sort fallback = Sort.by(Sort.Direction.DESC, "playtimeMinutes");
if (sort == null || sort.isBlank()) return fallback;
String[] parts = sort.split(",");
String field = parts[0].trim();
if (!SORTABLE_FIELDS.contains(field)) return fallback;
Sort.Direction dir = parts.length > 1 && "asc".equalsIgnoreCase(parts[1].trim())
? Sort.Direction.ASC : Sort.Direction.DESC;
return Sort.by(dir, field);
}
@PostMapping("/sync")
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) {
return ResponseEntity.badRequest().body(Map.of("code", "NO_STEAM_ID", "message", "User has no Steam ID"));
}
try {
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
return ResponseEntity.accepted().body(Map.of("status", "started"));
} catch (SyncCooldownException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds()))
.body(Map.of(
"code", "SYNC_COOLDOWN",
"message", e.getMessage(),
"retryAfterSeconds", e.getRetryAfterSeconds()));
}
}
@GetMapping("/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")));
}
@PutMapping("/games/{appid}/backlog")
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody BacklogEntryDto entry) {
return backlogService.update(principal.getIdAsLong(), appid, entry)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
}
@GetMapping("/activity")
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "30") int days) {
int safeDays = Math.clamp(days, MIN_ACTIVITY_DAYS, MAX_ACTIVITY_DAYS);
return ResponseEntity.ok(queryService.getActivity(principal.getIdAsLong(), safeDays));
}
@GetMapping("/wishlist")
public ResponseEntity<WishlistDto> getWishlist(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(queryService.getWishlist(principal.getIdAsLong()));
}
@GetMapping("/summary")
public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Long exclude) {
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
}
@GetMapping("/sync-status")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
return queryService.getSyncStatus(principal.getIdAsLong())
.map(s -> new SyncStatusDto(
s.getLastSyncedAt(),
s.getLastSyncStatus() != null ? s.getLastSyncStatus().name() : null))
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.ok(new SyncStatusDto(null, null)));
}
@GetMapping("/steam-profile")
public ResponseEntity<SteamProfileDto> getSteamProfile(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(steamProfileService.getProfile(principal.steamId()));
}
}
@@ -0,0 +1,35 @@
package core_service.modules.library.api;
import core_service.modules.library.service.LibraryQueryService;
import core_service.shared.security.ProfileTicketVerifier;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Optional;
/**
* Public, without login. Auth hands out the ticket only for a user whose profile is public, so this controller
* never needs to know the name or the privacy switch.
*/
@RestController
@RequestMapping("/api/public/profiles")
@RequiredArgsConstructor
public class PublicProfileController {
private final LibraryQueryService queryService;
private final ProfileTicketVerifier profileTicketVerifier;
@GetMapping("/library")
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
return (ticket == null ? Optional.<Long>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>map(userId -> ResponseEntity.ok(queryService.getPublicLibrary(userId)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
}
}
@@ -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,15 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.DayMinutes;
import java.util.List;
/**
* @param days one entry per day, oldest first, also days without playtime
* @param recentGames up to 5 games by Steam's playtime of the last two weeks
*/
public record ActivityDto(
List<DayMinutes> days,
long totalMinutes,
List<RecentGameDto> recentGames
) {}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Request and response of the backlog update. The request replaces all four fields; null clears an optional one.
*/
public record BacklogEntryDto(
@NotNull GameStatus status,
@Min(1) @Max(5) Integer rating,
@Size(max = 2000) String notes,
GamePriority priority
) {}
@@ -0,0 +1,35 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import java.time.Instant;
import java.util.List;
public record GameDetailDto(
Long appid,
String name,
String imgIconUrl,
int playtimeMinutes,
int playtime2Weeks,
Instant firstSeenAt,
Instant lastSeenAt,
GameStatus status,
Integer rating,
String notes,
GamePriority priority,
boolean hasAchievements,
boolean statsVisible,
int achievementsTotal,
int achievementsUnlocked,
int achievementsRemaining,
double completionPercent,
List<AchievementDto> achievements,
TimeToBeatDto timeToBeat
) {
/**
* IGDB's averages in minutes; each is null when unknown.
*/
public record TimeToBeatDto(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {}
}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
/**
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
*/
public record GameDto(
Long appid,
String name,
String imgIconUrl,
int playtimeMinutes,
GameStatus status,
Integer rating,
GamePriority priority,
Integer timeToBeatMinutes
) {}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import java.util.Map;
/**
* @param byStatus has every status, also those with 0 games
* @param suggestion a random backlog game, null when the backlog is empty
* @param backlogMinutes normal time to beat of the backlog games IGDB knows; 0 when none or IGDB is off
*/
public record LibrarySummaryDto(
long total,
Map<GameStatus, Long> byStatus,
long neverPlayed,
GameDto suggestion,
long backlogMinutes
) {}
@@ -0,0 +1,22 @@
package core_service.modules.library.api.dto;
import org.springframework.data.domain.Page;
import java.util.List;
public record PageDto<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageDto<T> of(Page<T> page) {
return new PageDto<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages());
}
}
@@ -0,0 +1,24 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import java.util.List;
import java.util.Map;
/**
* What a public profile shows. Never notes, priority or anything that identifies the Steam account.
*
* @param byStatus has every status, also those with 0 games
* @param playing up to 8 games, most played first
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
*/
public record PublicLibraryDto(
long totalGames,
long totalPlaytimeMinutes,
Map<GameStatus, Long> byStatus,
List<Game> playing,
List<Game> finished
) {
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {}
}
@@ -0,0 +1,14 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
/**
* @param playtime2Weeks Steam's playtime of the last two weeks, in minutes
*/
public record RecentGameDto(
Long appid,
String name,
int playtimeMinutes,
int playtime2Weeks,
GameStatus status
) {}
@@ -0,0 +1,9 @@
package core_service.modules.library.api.dto;
/**
* The user's Steam name and avatar; both are null without a Steam ID or when Steam does not answer.
*/
public record SteamProfileDto(
String personaName,
String avatarUrl
) {}
@@ -0,0 +1,8 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
public record SyncStatusDto(
Instant lastSyncedAt,
String lastSyncStatus
) {}
@@ -0,0 +1,16 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
import java.util.List;
/**
* @param games newest first, without the games the user owns
*/
public record WishlistDto(List<Game> games) {
/**
* @param price formatted by Steam, e.g. "7,49€"; null when the game has no price yet
* @param discountPct 0 when not on sale
*/
public record Game(Long appid, String name, Instant addedAt, String price, String originalPrice, int discountPct) {}
}
@@ -0,0 +1,5 @@
package core_service.modules.library.domain;
import java.time.LocalDate;
public record DayMinutes(LocalDate day, long minutes) {}
@@ -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,7 @@
package core_service.modules.library.domain;
public enum GamePriority {
LOW,
MEDIUM,
HIGH
}
@@ -0,0 +1,16 @@
package core_service.modules.library.domain;
public enum GameStatus {
BACKLOG,
PLAYING,
FINISHED,
COMPLETED,
DROPPED;
/**
* Status for a game the sync sees for the first time. Later syncs never change it.
*/
public static GameStatus initialFor(int playtimeMinutes) {
return playtimeMinutes > 0 ? PLAYING : BACKLOG;
}
}
@@ -0,0 +1,39 @@
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.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* IGDB's time to beat of one Steam app; every time is null when IGDB has no data for it.
*/
@Entity
@Table(name = "game_time_to_beat")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class GameTimeToBeat {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "hastily_minutes")
private Integer hastilyMinutes;
@Column(name = "normally_minutes")
private Integer normallyMinutes;
@Column(name = "completely_minutes")
private Integer completelyMinutes;
@Column(name = "fetched_at", nullable = false)
private Instant fetchedAt;
}
@@ -0,0 +1,38 @@
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 java.time.LocalDate;
/**
* Written only through {@code PlaytimeDayRepository.addMinutes}.
*/
@Entity
@Table(name = "user_game_playtime_day")
@IdClass(PlaytimeDayId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PlaytimeDay {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "day")
private LocalDate day;
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "minutes", nullable = false)
private int minutes;
}
@@ -0,0 +1,20 @@
package core_service.modules.library.domain;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDate;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EqualsAndHashCode
public class PlaytimeDayId implements Serializable {
private Long userId;
private LocalDate day;
private Long appid;
}
@@ -0,0 +1,3 @@
package core_service.modules.library.domain;
public record StatusCount(GameStatus status, long count) {}
@@ -0,0 +1,40 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Entity
@Table(name = "steam_game")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SteamGame {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "name", nullable = false, length = 512)
private String name;
@Column(name = "img_icon_url", length = 512)
private String imgIconUrl;
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt;
public SteamGame(Long appid, String name, String imgIconUrl, Instant lastSeenAt) {
this.appid = appid;
this.name = name;
this.imgIconUrl = imgIconUrl;
this.lastSeenAt = lastSeenAt;
}
}
@@ -0,0 +1,73 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.DynamicUpdate;
import java.time.Instant;
@Entity
// The sync and the backlog update run in separate transactions on the same row; each must write only its own columns.
@DynamicUpdate
@Table(name = "user_game")
@IdClass(UserGameId.class)
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserGame {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "playtime_minutes", nullable = false)
private int playtimeMinutes;
@Column(name = "playtime_2weeks", nullable = false)
private int playtime2Weeks;
@Column(name = "first_seen_at", nullable = false)
private Instant firstSeenAt;
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt;
@Column(name = "removed_at")
private Instant removedAt;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private GameStatus status;
@Column(name = "rating")
private Integer rating;
@Column(name = "notes", length = 2000)
private String notes;
@Enumerated(EnumType.STRING)
@Column(name = "priority", length = 8)
private GamePriority priority;
public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) {
this.userId = userId;
this.appid = appid;
this.playtimeMinutes = playtimeMinutes;
this.firstSeenAt = firstSeenAt;
this.lastSeenAt = lastSeenAt;
this.status = GameStatus.initialFor(playtimeMinutes);
}
}
@@ -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,19 @@
package core_service.modules.library.domain;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class UserGameId implements Serializable {
private Long userId;
private Long appid;
}
@@ -0,0 +1,49 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Entity
@Table(name = "user_steam_sync")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserSteamSync {
public enum Status { OK, FAILED, RUNNING }
@Id
@Column(name = "user_id")
private Long userId;
@Column(name = "steam_id", nullable = false, length = 32)
private String steamId;
@Column(name = "last_synced_at")
private Instant lastSyncedAt;
@Enumerated(EnumType.STRING)
@Column(name = "last_sync_status", length = 16)
private Status lastSyncStatus;
@Column(name = "history_steam_id", length = 32)
private String historySteamId;
@Column(name = "user_seen_at")
private Instant userSeenAt;
public UserSteamSync(Long userId, String steamId) {
this.userId = userId;
this.steamId = steamId;
}
}
@@ -0,0 +1,50 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* @see core_service.modules.library.repo.WishlistGameRepository
*/
@Entity
@Table(name = "wishlist_game")
@IdClass(WishlistGameId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class WishlistGame {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "appid")
private Long appid;
// Null when the store does not know the app (any more).
@Column(name = "name", length = 512)
private String name;
@Column(name = "added_at")
private Instant addedAt;
// Formatted by Steam for the store country, e.g. "7,49€". Null for an app without a price.
@Column(name = "price", length = 32)
private String price;
@Column(name = "original_price", length = 32)
private String originalPrice;
@Column(name = "discount_pct", nullable = false)
private int discountPct;
}
@@ -0,0 +1,18 @@
package core_service.modules.library.domain;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EqualsAndHashCode
public class WishlistGameId implements Serializable {
private Long userId;
private Long appid;
}
@@ -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,33 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameTimeToBeat;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Collection;
import java.util.Set;
public interface GameTimeToBeatRepository extends JpaRepository<GameTimeToBeat, Long> {
@Query("select t.appid from GameTimeToBeat t where t.appid in :appids and t.fetchedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
// An upsert, not save(): two users' syncs can store the same new app at the same time.
@Modifying
@Transactional
@Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " +
"values (:appid, :hastily, :normally, :completely, :fetchedAt) " +
"on conflict (appid) do update set hastily_minutes = excluded.hastily_minutes, " +
"normally_minutes = excluded.normally_minutes, completely_minutes = excluded.completely_minutes, " +
"fetched_at = excluded.fetched_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid,
@Param("hastily") Integer hastilyMinutes,
@Param("normally") Integer normallyMinutes,
@Param("completely") Integer completelyMinutes,
@Param("fetchedAt") Instant fetchedAt);
}
@@ -0,0 +1,31 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.PlaytimeDay;
import core_service.modules.library.domain.PlaytimeDayId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
@Modifying
@Transactional
@Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) values (:userId, :day, :appid, :minutes) " +
"on conflict (user_id, day, appid) do update set minutes = user_game_playtime_day.minutes + excluded.minutes",
nativeQuery = true)
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
@Param("day") LocalDate day, @Param("minutes") int minutes);
/**
* Only days with playtime; the caller fills the gaps.
*/
@Query("select new core_service.modules.library.domain.DayMinutes(p.day, sum(p.minutes)) from PlaytimeDay p " +
"where p.userId = :userId and p.day >= :from group by p.day order by p.day")
List<DayMinutes> minutesPerDay(@Param("userId") Long userId, @Param("from") LocalDate from);
}
@@ -0,0 +1,7 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.SteamGame;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
}
@@ -0,0 +1,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,109 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.JpaSort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
long countByUserIdAndRemovedAtIsNull(Long userId);
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null")
long sumPlaytime(@Param("userId") Long userId);
List<UserGame> findByUserIdAndRemovedAtIsNullAndStatusIn(Long userId, Collection<GameStatus> statuses, Pageable pageable);
List<UserGame> findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(
Long userId, int playtime2Weeks);
long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes);
@Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " +
"where ug.userId = :userId and ug.removedAt is null group by ug.status")
List<StatusCount> countByStatus(@Param("userId") Long userId);
/**
* @param excludeAppid never picked; -1 excludes nothing
*/
@Query(value = "select * from user_game where user_id = :userId and removed_at is null and status = 'BACKLOG' " +
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
/**
* @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
*/
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"and (:status is null or ug.status = :status) " +
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))")
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
@Param("status") GameStatus status,
@Param("namePattern") String namePattern,
@Param("maxMinutes") Integer maxMinutes,
Pageable pageable);
/**
* Normal time to beat of the user's backlog, only games IGDB has data for.
*/
@Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
long sumBacklogMinutes(@Param("userId") Long userId);
/**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
* appid breaks every tie so pages do not repeat or skip games.
*/
static Sort querySort(Sort requested) {
Sort result = Sort.unsorted();
for (Sort.Order order : requested) {
result = result.and(switch (order.getProperty()) {
case "rating" -> Sort.by(order.nullsLast());
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
.iterator().next().nullsLast());
default -> Sort.by(order);
});
}
return result.and(Sort.by("appid"));
}
/**
* Case-insensitive "contains" pattern for {@link #findOwnedGames}; % and _ in the query match literally.
*/
static String containsPattern(String query) {
if (query == null || query.isBlank()) return null;
String escaped = query.trim().toLowerCase(Locale.ROOT)
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
}
@Modifying
@Query("update UserGame ug set ug.removedAt = :now " +
"where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null")
int softDeleteMissingSince(@Param("userId") Long userId,
@Param("syncStartedAt") Instant syncStartedAt,
@Param("now") Instant now);
}
@@ -0,0 +1,57 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.UserSteamSync;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Long> {
/**
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
* (row created, or existing row was not RUNNING / RUNNING marker stale).
* Returns 0 if another sync is already in flight — caller must skip.
*/
@Modifying
@Query(value = """
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status)
VALUES (:userId, :steamId, 'RUNNING')
ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING'
WHERE user_steam_sync.last_sync_status <> 'RUNNING'
OR user_steam_sync.last_synced_at IS NULL
OR user_steam_sync.last_synced_at < :runningStaleBefore
""", nativeQuery = true)
int tryClaimRun(@Param("userId") Long userId,
@Param("steamId") String steamId,
@Param("runningStaleBefore") Instant runningStaleBefore);
@Modifying
@Query(value = """
UPDATE user_steam_sync
SET last_synced_at = :now,
last_sync_status = :status
WHERE user_id = :userId
""", nativeQuery = true)
void markFinished(@Param("userId") Long userId,
@Param("status") String status,
@Param("now") Instant now);
@Modifying
@Query(value = "UPDATE user_steam_sync SET history_steam_id = :steamId WHERE user_id = :userId", nativeQuery = true)
void markHistoryBaseline(@Param("userId") Long userId, @Param("steamId") String steamId);
@Modifying
@Transactional
@Query(value = "UPDATE user_steam_sync SET user_seen_at = :now WHERE user_id = :userId", nativeQuery = true)
void touchUserSeen(@Param("userId") Long userId, @Param("now") Instant now);
List<UserSteamSync> findByUserSeenAtAfter(Instant since);
}
@@ -0,0 +1,22 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.domain.WishlistGameId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface WishlistGameRepository extends JpaRepository<WishlistGame, WishlistGameId> {
// Newest first; games without a date last.
@Query("select w from WishlistGame w where w.userId = :userId order by w.addedAt desc nulls last, w.appid")
List<WishlistGame> findForUser(@Param("userId") Long userId);
// flushAutomatically: the delete must reach the DB before the saveAll of the same appids.
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from WishlistGame w where w.userId = :userId")
void deleteByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,38 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.UserGameRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class BacklogService {
private final UserGameRepository userGameRepository;
/**
* @return empty when the game is not in the user's active library
*/
@Transactional
public Optional<BacklogEntryDto> update(Long userId, Long appid, BacklogEntryDto entry) {
return userGameRepository.findById(new UserGameId(userId, appid))
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
game.setStatus(entry.status());
game.setRating(entry.rating());
game.setNotes(entry.notes() == null || entry.notes().isBlank() ? null : entry.notes());
game.setPriority(entry.priority());
return toDto(game);
});
}
private static BacklogEntryDto toDto(UserGame game) {
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
}
}
@@ -0,0 +1,196 @@
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.GameTimeToBeatRepository;
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;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
@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.getPlaytime2Weeks(),
userGame.getFirstSeenAt(),
userGame.getLastSeenAt(),
userGame.getStatus(),
userGame.getRating(),
userGame.getNotes(),
userGame.getPriority(),
hasAchievements,
statsVisible,
total,
unlocked,
remaining,
Math.round(pct * 10.0) / 10.0,
achievements,
gameTimeToBeatRepository.findById(userGame.getAppid())
.map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes()))
.orElse(null)
);
}
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));
}
}
@@ -0,0 +1,173 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LibraryQueryService {
private static final int PUBLIC_PLAYING = 8;
private static final int PUBLIC_FINISHED = 12;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
private final SteamGameRepository steamGameRepository;
private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
public long countOwnedGames(Long userId) {
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
}
public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Integer maxMinutes,
Pageable pageable) {
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames(
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid())));
}
/**
* @param excludeAppid the current suggestion, so a new pick differs from it; null for none
*/
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
Map<GameStatus, Long> byStatus = countByStatus(userId);
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
.map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null),
gameTimeToBeatRepository.findById(ug.getAppid()).orElse(null)))
.orElse(null);
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
}
/**
* @param days the number of days up to and including today
*/
public ActivityDto getActivity(Long userId, int days) {
LocalDate today = LocalDate.now(zone);
LocalDate from = today.minusDays(days - 1L);
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
List<DayMinutes> perDay = from.datesUntil(today.plusDays(1))
.map(day -> new DayMinutes(day, played.getOrDefault(day, 0L)))
.toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
List<UserGame> recent = userGameRepository
.findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(userId, 0);
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
List<RecentGameDto> recentGames = recent.stream()
.map(ug -> new RecentGameDto(
ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(),
ug.getPlaytime2Weeks(),
ug.getStatus()))
.toList();
return new ActivityDto(perDay, total, recentGames);
}
public PublicLibraryDto getPublicLibrary(Long userId) {
Map<GameStatus, Long> byStatus = countByStatus(userId);
List<UserGame> playing = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
List.of(GameStatus.PLAYING),
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
List<UserGame> finished = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))));
Map<Long, SteamGame> meta = steamGameRepository.findAllById(
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
toPublicGames(playing, meta), toPublicGames(finished, meta));
}
// Every status, also those with 0 games.
private Map<GameStatus, Long> countByStatus(Long userId) {
Map<GameStatus, Long> byStatus = new EnumMap<>(GameStatus.class);
for (GameStatus status : GameStatus.values()) {
byStatus.put(status, 0L);
}
userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count()));
return byStatus;
}
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
return games.stream()
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating()))
.toList();
}
public WishlistDto getWishlist(Long userId) {
return new WishlistDto(wishlistGameRepository.findForUser(userId).stream()
.map(w -> new WishlistDto.Game(w.getAppid(), w.getName(), w.getAddedAt(),
w.getPrice(), w.getOriginalPrice(), w.getDiscountPct()))
.toList());
}
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time) {
return new GameDto(
ug.getAppid(),
meta != null ? meta.getName() : null,
meta != null ? meta.getImgIconUrl() : null,
ug.getPlaytimeMinutes(),
ug.getStatus(),
ug.getRating(),
ug.getPriority(),
time != null ? time.getNormallyMinutes() : null);
}
public Optional<UserSteamSync> getSyncStatus(Long userId) {
return userSteamSyncRepository.findById(userId);
}
}
@@ -0,0 +1,107 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.UserSteamSyncRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class LibrarySyncService {
private final UserSteamSyncRepository userSteamSyncRepository;
private final LibrarySyncWorker worker;
@Value("${app.library.sync-ttl-hours:24}")
private long syncTtlHours;
@Value("${app.library.force-sync-cooldown-seconds:60}")
private long forceSyncCooldownSeconds;
@Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes;
@Value("${app.library.failed-retry-minutes:15}")
private long failedRetryMinutes;
@Value("${app.library.nightly-active-days:14}")
private long nightlyActiveDays;
/**
* Trigger sync only if data is older than TTL or was synced for another Steam ID.
* Cheap gate check on caller thread, actual sync runs async via worker.
*/
public void syncIfStale(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
if (sync != null && isAlreadyRunning(sync)) {
log.debug("Sync already running for user {}", userId);
return;
}
if (sync != null && steamId.equals(sync.getSteamId()) && !isStale(sync)) {
return;
}
worker.runSync(userId, steamId);
}
/**
* Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each
* day even when the user does not open the app. Accounts are synced one after the other on the scheduler
* thread; accounts without a user request in the last {@code nightly-active-days} are skipped.
*/
@Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void syncAllNightly() {
Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays));
List<UserSteamSync> accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince);
log.info("Nightly sync for {} Steam accounts", accounts.size());
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId()));
}
/**
* User-triggered sync. Throws SyncCooldownException if recently synced or running.
*/
public void forceSync(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
if (sync != null && sync.getLastSyncedAt() != null) {
long sinceLast = Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds();
if (sinceLast < forceSyncCooldownSeconds) {
throw new SyncCooldownException(forceSyncCooldownSeconds - sinceLast);
}
}
if (sync != null && isAlreadyRunning(sync)) {
throw new SyncCooldownException(forceSyncCooldownSeconds);
}
worker.runSync(userId, steamId);
}
// At most one write per user and hour; the nightly sync only needs to know "used recently".
private void touchUserSeen(UserSteamSync sync) {
Instant now = Instant.now();
if (sync != null && (sync.getUserSeenAt() == null || sync.getUserSeenAt().isBefore(now.minus(Duration.ofHours(1))))) {
userSteamSyncRepository.touchUserSeen(sync.getUserId(), now);
}
}
private boolean isStale(UserSteamSync sync) {
if (sync.getLastSyncedAt() == null) return true;
Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED
? Duration.ofMinutes(failedRetryMinutes)
: Duration.ofHours(syncTtlHours);
return sync.getLastSyncedAt().isBefore(Instant.now().minus(maxAge));
}
private boolean isAlreadyRunning(UserSteamSync sync) {
if (sync.getLastSyncStatus() != UserSteamSync.Status.RUNNING) return false;
if (sync.getLastSyncedAt() == null) return true;
return sync.getLastSyncedAt().isAfter(Instant.now().minus(Duration.ofMinutes(runningStaleMinutes)));
}
}
@@ -0,0 +1,196 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import core_service.modules.library.domain.UserSteamSync.Status;
/**
* Worker bean. Runs the actual Steam fetch + DB upserts.
* Separate bean so Spring proxy applies @Async and @Transactional
* (self-invocation from LibrarySyncService would bypass both).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LibrarySyncWorker {
private final SteamApiService steamApiService;
private final SteamGameRepository steamGameRepository;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository;
private final TimeToBeatService timeToBeatService;
@Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@Async
@Transactional
public void runSync(Long userId, String steamId) {
sync(userId, steamId);
}
/**
* Same as {@link #runSync}, but on the caller's thread. The nightly job uses it to sync one account after
* the other instead of starting all at once against the Steam rate limit and the connection pool.
*/
@Transactional
public void runSyncNow(Long userId, String steamId) {
sync(userId, steamId);
}
private void sync(Long userId, String steamId) {
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
UserSteamSync before = userSteamSyncRepository.findById(userId).orElse(null);
String previousSteamId = before != null ? before.getSteamId() : null;
String historySteamId = before != null ? before.getHistorySteamId() : null;
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
if (claimed == 0) {
log.info("Sync skipped for user {} — another run already in flight", userId);
return;
}
Instant syncStartedAt = Instant.now();
Status finalStatus = Status.FAILED;
try {
boolean steamIdChanged = previousSteamId != null && !previousSteamId.equals(steamId);
// Growth needs a successful earlier sync of this account; else all its playtime would count as today.
boolean recordPlaytime = steamId.equals(historySteamId);
LocalDate today = LocalDate.ofInstant(syncStartedAt, zone);
if (steamIdChanged) {
// The old games belong to the previous Steam account. The upserts below restore the games this account also owns.
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
wishlistGameRepository.deleteByUserId(userId);
}
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId);
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
// Before the empty check: an account without games can still have a public wishlist.
syncWishlist(userId, steamId, games);
if (games.isEmpty()) {
// Steam errors and a private "Game details" setting also end here. A soft-delete would wipe the library.
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
return;
}
for (SteamOwnedGamesResponse.Game g : games) {
if (g.appid() == null) continue;
upsertGame(g, syncStartedAt);
upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null);
}
int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
if (removed > 0) {
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
}
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
finalStatus = Status.OK;
} catch (Exception e) {
log.error("Sync failed for user {}", userId, e);
} finally {
userSteamSyncRepository.markFinished(userId, finalStatus.name(), Instant.now());
}
}
private void syncWishlist(Long userId, String steamId, List<SteamOwnedGamesResponse.Game> ownedGames) {
Optional<List<SteamApiService.WishlistItem>> wishlist = steamApiService.getWishlist(steamId);
if (wishlist.isEmpty()) {
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
return;
}
Set<Long> owned = ownedGames.stream()
.filter(g -> g.appid() != null)
.map(g -> g.appid().longValue())
.collect(Collectors.toSet());
List<SteamApiService.WishlistItem> wanted = wishlist.get().stream()
.filter(item -> !owned.contains(item.appid()))
.toList();
Map<Long, SteamApiService.StoreItem> store = wanted.isEmpty()
? Map.of()
: steamApiService.getStoreItems(wanted.stream().map(SteamApiService.WishlistItem::appid).toList());
// A failed store batch leaves apps out of `store`; they keep the name and price of the last sync.
Map<Long, WishlistGame> previous = wishlistGameRepository.findForUser(userId).stream()
.collect(Collectors.toMap(WishlistGame::getAppid, w -> w));
wishlistGameRepository.deleteByUserId(userId);
wishlistGameRepository.saveAll(wanted.stream().map(item -> {
SteamApiService.StoreItem info = store.get(item.appid());
if (info != null) {
return new WishlistGame(userId, item.appid(), info.name(), item.addedAt(),
info.price(), info.originalPrice(), info.discountPct());
}
WishlistGame old = previous.get(item.appid());
return old == null
? new WishlistGame(userId, item.appid(), null, item.addedAt(), null, null, 0)
: new WishlistGame(userId, item.appid(), old.getName(), item.addedAt(),
old.getPrice(), old.getOriginalPrice(), old.getDiscountPct());
}).toList());
}
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now) {
Long appid = g.appid().longValue();
SteamGame existing = steamGameRepository.findById(appid).orElse(null);
if (existing == null) {
steamGameRepository.save(new SteamGame(appid, g.name(), g.imgIconUrl(), now));
} else {
existing.setName(g.name());
existing.setImgIconUrl(g.imgIconUrl());
existing.setLastSeenAt(now);
}
}
/**
* @param playedOn the day that gets the playtime growth, null to record none
*/
private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
Long appid = g.appid().longValue();
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
if (existing == null) {
UserGame created = new UserGame(userId, appid, playtime, now, now);
created.setPlaytime2Weeks(playtime2Weeks);
userGameRepository.save(created);
} else {
existing.setPlaytimeMinutes(playtime);
existing.setPlaytime2Weeks(playtime2Weeks);
existing.setLastSeenAt(now);
existing.setRemovedAt(null);
}
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted.
if (playedOn != null && playtime > previousPlaytime) {
playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime);
}
}
}
@@ -0,0 +1,24 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.steam_adapter.service.SteamApiService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class SteamProfileService {
private static final SteamProfileDto NONE = new SteamProfileDto(null, null);
private final SteamApiService steamApiService;
public SteamProfileDto getProfile(Long steamId) {
if (steamId == null) return NONE;
return steamApiService.getPlayerSummary(String.valueOf(steamId))
// The frontend puts the avatar into an img src, so only an https URL passes.
.map(player -> new SteamProfileDto(player.personaname(),
player.avatarfull() != null && player.avatarfull().startsWith("https://") ? player.avatarfull() : null))
.orElse(NONE);
}
}
@@ -0,0 +1,14 @@
package core_service.modules.library.service;
public class SyncCooldownException extends RuntimeException {
private final long retryAfterSeconds;
public SyncCooldownException(long retryAfterSeconds) {
super("Sync on cooldown, retry in " + retryAfterSeconds + "s");
this.retryAfterSeconds = retryAfterSeconds;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
}
}
@@ -0,0 +1,55 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@Service
@RequiredArgsConstructor
@Slf4j
public class TimeToBeatService {
private final IgdbApiService igdbApiService;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
@Value("${app.library.time-to-beat-ttl-days:30}")
private long ttlDays;
/**
* Asks IGDB for the apps without a fresh row and stores the answer, also "no data". Does nothing when IGDB is
* off, and stores nothing when IGDB fails, so the apps are asked again on the next sync.
*/
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
// Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock.
Set<Long> stale = new TreeSet<>(appids);
stale.removeAll(gameTimeToBeatRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays))));
if (stale.isEmpty()) return;
Optional<Map<Long, IgdbApiService.TimeToBeat>> answer = igdbApiService.getTimeToBeat(stale);
if (answer.isEmpty()) {
log.warn("Time to beat for {} apps not refreshed, IGDB did not answer", stale.size());
return;
}
Instant now = Instant.now();
for (Long appid : stale) {
IgdbApiService.TimeToBeat time = answer.get().get(appid);
if (time == null) {
gameTimeToBeatRepository.upsert(appid, null, null, null, now);
} else {
gameTimeToBeatRepository.upsert(appid, time.hastilyMinutes(), time.normallyMinutes(), time.completelyMinutes(), now);
}
}
}
}
@@ -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,30 @@
package core_service.modules.steam_adapter.api;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/steam")
@RequiredArgsConstructor
public class SteamController {
private final SteamApiService steamApiService;
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) {
return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false));
}
int count = steamApiService.getOwnedGamesCount(String.valueOf(principal.steamId()));
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true));
}
}
@@ -0,0 +1,27 @@
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 SteamOwnedGamesResponse(
@JsonProperty("response") OwnedGamesResponse response
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record OwnedGamesResponse(
@JsonProperty("game_count") Integer gameCount,
@JsonProperty("games") List<Game> games
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Game(
@JsonProperty("appid") Integer appid,
@JsonProperty("name") String name,
@JsonProperty("playtime_forever") Integer playtimeForever,
@JsonProperty("img_icon_url") String imgIconUrl,
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
@JsonProperty("playtime_2weeks") Integer playtime2Weeks
) {}
}
@@ -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,15 @@
package core_service.modules.steam_adapter.api.dto.response;
import java.util.List;
public record SteamPlayerSummaryResponse(SteamResponse response) {
public record SteamResponse(List<Player> players) {}
public record Player(
String steamid,
String personaname,
String profileurl,
String avatarfull
) {}
}
@@ -0,0 +1,9 @@
package core_service.modules.steam_adapter.api.dto.response;
import java.util.List;
public record SteamProfileAndGamesResponse(
SteamPlayerSummaryResponse.Player player,
List<SteamOwnedGamesResponse.Game> games
) {
}
@@ -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
) {}
}
@@ -0,0 +1,36 @@
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 SteamStoreItemsResponse(
@JsonProperty("response") StoreItemsResponse response
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record StoreItemsResponse(
@JsonProperty("store_items") List<StoreItem> storeItems
) {}
/**
* @param success 1 for a known app
* @param bestPurchaseOption null for an app without a price, e.g. not released yet
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record StoreItem(
@JsonProperty("appid") Long appid,
@JsonProperty("success") Integer success,
@JsonProperty("name") String name,
@JsonProperty("best_purchase_option") PurchaseOption bestPurchaseOption
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record PurchaseOption(
@JsonProperty("formatted_final_price") String formattedFinalPrice,
@JsonProperty("formatted_original_price") String formattedOriginalPrice,
@JsonProperty("discount_pct") Integer discountPct
) {}
}
@@ -0,0 +1,26 @@
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 SteamWishlistResponse(
@JsonProperty("response") WishlistResponse response
) {
/**
* @param items null for an empty or private wishlist
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record WishlistResponse(
@JsonProperty("items") List<Item> items
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Item(
@JsonProperty("appid") Long appid,
@JsonProperty("date_added") Long dateAdded
) {}
}
@@ -0,0 +1,16 @@
package core_service.modules.steam_adapter.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class SteamClientConfig {
@Bean
public RestClient steamRestClient(@Value("${app.steam.base-url}") String baseUrl) {
return RestClient.builder()
.baseUrl(baseUrl)
.build();
}
}
@@ -0,0 +1,300 @@
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 core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
import org.springframework.web.client.HttpClientErrorException;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class SteamApiService {
private final RestClient steamRestClient;
private static final int STORE_ITEMS_BATCH = 100;
@Value("${app.steam.api-key}")
private String apiKey;
@Value("${app.steam.store-language:german}")
private String storeLanguage;
@Value("${app.steam.store-country:DE}")
private String storeCountry;
@Cacheable(value = "steamGameCount", key = "#steamId")
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public int getOwnedGamesCount(String steamId) {
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
log.error("Steam API Key is not configured correctly!");
return 0;
}
try {
log.info("Fetching owned games count for SteamID: {} using API Key starting with: {}",
steamId, apiKey.substring(0, Math.min(apiKey.length(), 4)));
SteamOwnedGamesResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IPlayerService/GetOwnedGames/v1/")
.queryParam("key", apiKey)
.queryParam("steamid", steamId)
.queryParam("include_appinfo", "true")
.queryParam("include_played_free_games", "true")
.queryParam("format", "json")
.build())
.retrieve()
.body(SteamOwnedGamesResponse.class);
log.info("Steam API Response object for {}: {}", steamId, response);
if (response != null && response.response() != null) {
Integer count = response.response().gameCount();
if (count == null) {
if (response.response().games() != null) {
int size = response.response().games().size();
log.info("game_count was null, but games list has {} entries for SteamID: {}", size, steamId);
return size;
}
log.warn("Steam API returned an empty response object for SteamID: {}. Check if the ID is correct and 'Game details' are Public.", steamId);
return 0;
}
log.info("Successfully fetched {} games for SteamID: {}", count, steamId);
return count;
}
} catch (Exception e) {
log.error("Error communicating with Steam API for game count of SteamID: {}", steamId, e);
}
return 0;
}
public Optional<SteamProfileAndGamesResponse> getFullPlayerProfile(String steamId) {
var playerOpt = getPlayerSummary(steamId);
if (playerOpt.isEmpty()) {
return Optional.empty();
}
var games = getOwnedGames(steamId);
return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games));
}
// Asked on every page load for the avatar in the header.
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public Optional<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
try {
log.info("Fetching Steam profile for SteamID: {}", steamId);
SteamPlayerSummaryResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/ISteamUser/GetPlayerSummaries/v0002/")
.queryParam("key", apiKey)
.queryParam("steamids", steamId)
.build())
.retrieve()
.body(SteamPlayerSummaryResponse.class);
if (response != null && response.response() != null && !response.response().players().isEmpty()) {
return Optional.of(response.response().players().getFirst());
}
} catch (Exception e) {
log.error("Error communicating with Steam API for SteamID: {}", steamId, e);
}
return Optional.empty();
}
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public List<SteamOwnedGamesResponse.Game> getOwnedGames(String steamId) {
try {
log.info("Fetching owned games for SteamID: {}", steamId);
SteamOwnedGamesResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IPlayerService/GetOwnedGames/v1/")
.queryParam("key", apiKey)
.queryParam("steamid", steamId)
.queryParam("include_appinfo", 1)
.queryParam("include_played_free_games", 1)
.build())
.retrieve()
.body(SteamOwnedGamesResponse.class);
if (response != null && response.response() != null && response.response().games() != null) {
return response.response().games();
}
} catch (Exception e) {
log.error("Error communicating with Steam API for games of SteamID: {}", steamId, e);
}
return Collections.emptyList();
}
public record WishlistItem(long appid, Instant addedAt) {}
/**
* @param discountPct 0 when not on sale
*/
public record StoreItem(String name, String price, String originalPrice, int discountPct) {}
/**
* Needs a public wishlist; a private one comes back as an empty list, like an empty one.
*
* @return empty on a Steam error, so the caller can keep the list it has
*/
@RateLimiter(name = "steamApi")
@Retry(name = "steamApi")
public Optional<List<WishlistItem>> getWishlist(String steamId) {
try {
SteamWishlistResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IWishlistService/GetWishlist/v1/")
.queryParam("key", apiKey)
.queryParam("steamid", steamId)
.build())
.retrieve()
.body(SteamWishlistResponse.class);
if (response == null || response.response() == null || response.response().items() == null) {
return Optional.of(List.of());
}
return Optional.of(response.response().items().stream()
.filter(item -> item.appid() != null)
.map(item -> new WishlistItem(item.appid(),
item.dateAdded() != null ? Instant.ofEpochSecond(item.dateAdded()) : null))
.toList());
} catch (Exception e) {
log.error("Error communicating with Steam API for the wishlist of SteamID: {}", steamId, e);
return Optional.empty();
}
}
/**
* Store name and current price per app, asked in batches of {@value #STORE_ITEMS_BATCH}. Apps Steam does not
* know, and batches that fail, are missing from the result. One rate-limit permit covers all batches; a
* wishlist rarely has more than one.
*/
@RateLimiter(name = "steamApi")
public Map<Long, StoreItem> getStoreItems(List<Long> appids) {
Map<Long, StoreItem> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += STORE_ITEMS_BATCH) {
result.putAll(getStoreItemsBatch(appids.subList(from, Math.min(from + STORE_ITEMS_BATCH, appids.size()))));
}
return result;
}
private Map<Long, StoreItem> getStoreItemsBatch(List<Long> appids) {
String ids = appids.stream().map(id -> "{\"appid\":" + id + "}").collect(Collectors.joining(","));
String inputJson = "{\"ids\":[" + ids + "],\"context\":{\"language\":\"" + storeLanguage
+ "\",\"country_code\":\"" + storeCountry + "\"},\"data_request\":{}}";
try {
SteamStoreItemsResponse response = steamRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/IStoreBrowseService/GetItems/v1/")
.queryParam("key", apiKey)
.queryParam("input_json", "{inputJson}")
.build(inputJson))
.retrieve()
.body(SteamStoreItemsResponse.class);
if (response == null || response.response() == null || response.response().storeItems() == null) {
return Map.of();
}
Map<Long, StoreItem> items = new HashMap<>();
for (SteamStoreItemsResponse.StoreItem item : response.response().storeItems()) {
if (item.appid() == null || !Integer.valueOf(1).equals(item.success())) continue;
SteamStoreItemsResponse.PurchaseOption price = item.bestPurchaseOption();
items.put(item.appid(), new StoreItem(
item.name(),
price != null ? price.formattedFinalPrice() : null,
price != null ? price.formattedOriginalPrice() : null,
price != null && price.discountPct() != null ? price.discountPct() : 0));
}
return items;
} catch (Exception e) {
log.error("Error communicating with Steam API for store items {}", appids, e);
return Map.of();
}
}
/**
* 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();
}
}
@@ -0,0 +1,14 @@
package core_service.shared.security;
import java.util.Optional;
/**
* Port for the public-profile ticket that Auth issues for a user with a public profile.
*/
public interface ProfileTicketVerifier {
/**
* @return the user of a valid, unexpired ticket; empty for anything else, also for a login token
*/
Optional<Long> verifyProfileTicket(String ticket);
}
@@ -4,7 +4,8 @@ import java.security.Principal;
public record UserPrincipal( public record UserPrincipal(
String id, String id,
String email String email,
Long steamId
) implements Principal { ) implements Principal {
@Override @Override
@@ -16,3 +17,4 @@ public record UserPrincipal(
return Long.parseLong(id); return Long.parseLong(id);
} }
} }
+57 -13
View File
@@ -3,35 +3,79 @@ spring:
name: tbp-core-service name: tbp-core-service
profiles: profiles:
active: prod active: prod
datasource: jackson:
url: jdbc:postgresql://${DB_HOST}:5432/tbp_core deserialization:
username: ${DB_USER} accept-float-as-int: false
password: ${DB_PASS}
jpa: jpa:
open-in-view: true open-in-view: true
hibernate:
ddl-auto: validate
flyway: flyway:
enabled: true enabled: true
baseline-on-migrate: true baseline-on-migrate: true
locations: classpath:db/migration locations: classpath:db/migration
threads:
virtual:
enabled: true
mvc:
apiversion:
use:
query-parameter: version
default: "1.0"
supported: 1.0, 2,0
server: server:
port: 8081 port: 8090
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health, prometheus, metrics include: health
endpoint:
health:
show-details: never
security: security:
jwt: jwt:
secret: ${JWT_SECRET} public-key: ${JWT_PUBLIC_KEY}
app: app:
cors: cors:
allowed-origins: "https://tbp.andreas-dahm.eu" allowed-origins: "https://tbp.andreas-dahm.eu"
steam:
api-key: ${STEAM_API_KEY}
base-url: https://api.steampowered.com
imageService-url: https://cdn.cloudflare.steamstatic.com/steam/apps/
igdb:
# A Twitch developer app (dev.twitch.tv). Without both values time to beat is off.
client-id: ${IGDB_CLIENT_ID:}
client-secret: ${IGDB_CLIENT_SECRET:}
base-url: https://api.igdb.com/v4
token-base-url: https://id.twitch.tv
library:
sync-ttl-hours: 24
force-sync-cooldown-seconds: 60
running-stale-minutes: 5
achievement-ttl-hours: 1
schema-ttl-days: 30
internal: resilience4j:
api-key: ${INTERNAL_API_KEY} retry:
instances:
steamApi:
max-attempts: 3
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- org.springframework.web.client.RestClientException
- org.springframework.web.client.ResourceAccessException
ratelimiter:
instances:
steamApi:
# Steam allows ~100k req/day. Cap at ~2 req/s to leave headroom for bursts.
limit-for-period: 2
limit-refresh-period: 1s
timeout-duration: 5s
igdbApi:
# IGDB allows 4 requests/s. Taken per HTTP request.
limit-for-period: 4
limit-refresh-period: 1s
timeout-duration: 10s
+18
View File
@@ -0,0 +1,18 @@
,----,
,/ .`| ,-.----.
,` .' : ,---,. \ / \ ,--, ,----..
; ; / ,' .' \ | : \ ,--.'| ,---, / / \
.'___,/ ,' ,---. ,---.' .' | | | .\ :| | : ,---.'| ,---,.| : : ,---. __ ,-.
| : | ' ,'\ | | |: | . : |: |: : ' | | : ,' .' |. | ;. / ' ,'\ ,' ,'/ /|
; |.'; ; / / |: : : / ,---. | | \ :| ' | ,--.--. .--, ,---. | | |,---.' ,. ; /--` / / |' | |' | ,---.
`----' | |. ; ,. :: | ; / \ | : . /' | | / \ /_ ./| / \ ,--.__| || | |; | ; . ; ,. :| | ,'/ \
' : ;' | |: :| : \ / / |; | |`-' | | : .--. .-. | , ' , ' : / / | / ,' |: : .' | : | ' | |: :' : / / / |
| | '' | .; :| | . |. ' / || | ; ' : |__ \__\/: . ./___/ \: |. ' / |. ' / |: |.' . | '___' | .; :| | ' . ' / |
' : || : |' : '; |' ; /|: ' | | | '.'| ," .--.; | . \ ' |' ; /|' ; |: |`---' ' ; : .'| : |; : | ' ; /|
; |.' \ \ / | | | ; ' | / |: : : ; : ;/ / ,. | \ ; :' | / || | '/ ' ' | '/ :\ \ / | , ; ' | / |
'---' `----' | : / | : || | : | , /; : .' \ \ \ ;| : || : :| | : / `----' ---' | : |
| | ,' \ \ / `---'.| ---`-' | , .-./ : \ \\ \ / \ \ / \ \ .' \ \ /
`----' `----' `---` `--`---' \ ' ; `----' `----' `---` `----'
`--`
${application.title} ${application.version}
Powered by Spring Boot ${spring-boot.version}
@@ -0,0 +1,27 @@
-- Global Steam game catalog. Shared across users to avoid duplicating metadata.
CREATE TABLE steam_game (
appid BIGINT PRIMARY KEY,
name VARCHAR(512) NOT NULL,
img_icon_url VARCHAR(512),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Per-user ownership and playtime. Soft-delete via removed_at.
CREATE TABLE user_game (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL REFERENCES steam_game(appid),
playtime_minutes INTEGER NOT NULL DEFAULT 0,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
PRIMARY KEY (user_id, appid)
);
CREATE INDEX idx_user_game_user_active ON user_game (user_id) WHERE removed_at IS NULL;
-- Per-user Steam sync state. Owned by Core to avoid coupling Auth schema.
CREATE TABLE user_steam_sync (
user_id BIGINT PRIMARY KEY,
steam_id VARCHAR(32) NOT NULL,
last_synced_at TIMESTAMPTZ,
last_sync_status VARCHAR(16)
);
@@ -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)
);
@@ -0,0 +1,12 @@
-- Per-user backlog tracking. The sync sets status only when it inserts a game.
ALTER TABLE user_game
ADD COLUMN status VARCHAR(16),
ADD COLUMN rating INTEGER CHECK (rating BETWEEN 1 AND 5),
ADD COLUMN notes VARCHAR(2000),
ADD COLUMN priority VARCHAR(8);
UPDATE user_game SET status = CASE WHEN playtime_minutes > 0 THEN 'PLAYING' ELSE 'BACKLOG' END;
ALTER TABLE user_game ALTER COLUMN status SET NOT NULL;
CREATE INDEX idx_user_game_user_status ON user_game (user_id, status) WHERE removed_at IS NULL;
@@ -0,0 +1,21 @@
-- Minutes played per user, game and day (Europe/Berlin), as the growth of playtime_forever between two syncs.
-- The first sync of a Steam account is only the baseline and writes no rows.
CREATE TABLE user_game_playtime_day (
user_id BIGINT NOT NULL,
day DATE NOT NULL,
appid BIGINT NOT NULL,
minutes INTEGER NOT NULL CHECK (minutes > 0),
PRIMARY KEY (user_id, day, appid)
);
-- The Steam account whose playtime is the baseline for the history: set after a successful sync only.
-- Growth is recorded only while it equals steam_id, so a failed first sync of a new account cannot turn
-- that account's whole playtime into one day.
ALTER TABLE user_steam_sync ADD COLUMN history_steam_id VARCHAR(32);
-- Last request of the user. The nightly sync skips accounts nobody used for a while, e.g. after the user
-- removed the Steam ID (Core does not learn about that).
ALTER TABLE user_steam_sync ADD COLUMN user_seen_at TIMESTAMPTZ;
-- Steam's own "last two weeks" playtime, updated on every sync.
ALTER TABLE user_game ADD COLUMN playtime_2weeks INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,12 @@
-- The user's Steam wishlist without the games already owned. The sync replaces it as a whole.
-- Name and price come from the Steam store at sync time.
CREATE TABLE wishlist_game (
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
name VARCHAR(512),
added_at TIMESTAMPTZ,
price VARCHAR(32),
original_price VARCHAR(32),
discount_pct INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, appid)
);
@@ -0,0 +1,9 @@
-- Time to beat per Steam app from IGDB, shared by all users. A row with only NULL times means IGDB has no data;
-- it is kept so the app is not asked again before the row is stale.
CREATE TABLE game_time_to_beat (
appid BIGINT PRIMARY KEY,
hastily_minutes INTEGER,
normally_minutes INTEGER,
completely_minutes INTEGER,
fetched_at TIMESTAMPTZ NOT NULL
);
@@ -0,0 +1,102 @@
package core_service.architecture;
import core_service.shared.test.AbstractArchitectureTest;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/**
* ArchUnit tests to enforce package boundaries in core-service.
*
* Notes on Core-specific structure (differs from auth-service):
* - JPA entities live in `..domain..` (not `..persistence..`).
* - Repository interfaces live in `..repo..`.
* - `steam_adapter` is an outbound port consumed by feature modules
* (e.g. `library`), so strict slice independence is relaxed for it.
*/
@AnalyzeClasses(packages = "core_service", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest extends AbstractArchitectureTest {
// ===================================================================================
// 1. MODULE INDEPENDENCE (Slices)
// ===================================================================================
@ArchTest
static final ArchRule modules_should_not_depend_on_each_other = SlicesRuleDefinition.slices()
.matching("core_service.modules.(*)..")
.should().notDependOnEachOther()
.ignoreDependency(
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter.."))
.because("Feature modules should be isolated; steam_adapter and igdb_adapter are explicit shared ports.");
@ArchTest
static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices()
.matching("core_service.(*)..")
.should().beFreeOfCycles()
.because("Cyclic dependencies make the code unmaintainable and tightly coupled.");
// ===================================================================================
// 2. LAYER DEPENDENCY DIRECTION
// ===================================================================================
@ArchTest
static final ArchRule apis_should_not_depend_on_repos = noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..repo..")
.because("Controllers must delegate to Services and not access repositories directly.");
@ArchTest
static final ArchRule repos_should_not_depend_on_apis = noClasses()
.that().resideInAPackage("..repo..")
.should().dependOnClassesThat().resideInAPackage("..api..")
.because("Persistence layer must be independent of the API layer.");
@ArchTest
static final ArchRule repos_should_be_interfaces_named_repository = classes()
.that().resideInAPackage("..repo..")
.should().haveSimpleNameEndingWith("Repository")
.because("Repository interfaces are the persistence boundary; naming makes them explicit.");
// ===================================================================================
// 3. SHARED / CONFIGURATION RULES
// ===================================================================================
@ArchTest
static final ArchRule shared_should_not_depend_on_modules = noClasses()
.that().resideInAPackage("core_service.shared..")
.should().dependOnClassesThat().resideInAPackage("core_service.modules..")
.because("Shared classes should be independent of specific modules.");
@ArchTest
static final ArchRule modules_should_not_depend_on_global_configuration = noClasses()
.that().resideInAPackage("core_service.modules..")
.should().dependOnClassesThat().resideInAPackage("core_service.configuration..")
.because("Module logic should not depend on global Spring configuration classes.");
// ===================================================================================
// 4. SPRING ANNOTATION CONVENTIONS
// ===================================================================================
@ArchTest
static final ArchRule services_should_be_annotated = classes()
.that().resideInAPackage("..modules..service..")
.and().haveSimpleNameEndingWith("Service")
.should().beAnnotatedWith(org.springframework.stereotype.Service.class)
.because("Classes named *Service in modules should be registered as Spring @Service beans.");
@ArchTest
static final ArchRule controllers_should_be_annotated = classes()
.that().resideInAPackage("..api..")
.and().haveSimpleNameEndingWith("Controller")
.should().beAnnotatedWith(org.springframework.web.bind.annotation.RestController.class)
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class)
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class)
.because("Classes in 'api' package named *Controller should be Spring REST controllers.");
}
@@ -0,0 +1,136 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.util.Base64;
import java.util.Date;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
/**
* Tests Core's verify-only JwtService against tokens signed with a matching Ed25519 private key.
*/
class JwtServiceTest extends AbstractUnitTest {
@Mock
private JwtConfig jwtConfig;
private JwtService jwtService;
private PrivateKey signingKey;
@BeforeEach
void setUp() throws Exception {
KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
signingKey = keyPair.getPrivate();
String publicB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
jwtService = new JwtService(jwtConfig);
jwtService.init();
}
private String signToken(String userId, String email, Long steamId) {
var builder = Jwts.builder()
.subject(email)
.claim("userId", userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 60_000));
if (steamId != null) {
builder.claim("steamId", steamId);
}
return builder.signWith(signingKey, Jwts.SIG.EdDSA).compact();
}
@Test
void extractEmail_Success() {
var email = "test@example.com";
var token = signToken("0815", email, null);
assertEquals(email, jwtService.extractEmail(token));
}
@Test
void extractUserId_Success() {
var userId = "0815";
var token = signToken(userId, "test@example.com", null);
assertEquals(userId, jwtService.extractUserId(token));
}
@Test
void extractSteamId_Success() {
var steamId = 123456789L;
var token = signToken("0815", "test@example.com", steamId);
assertEquals(steamId, jwtService.extractSteamId(token));
}
@Test
void isTokenValid_Success() {
var token = signToken("0815", "test@example.com", null);
assertTrue(jwtService.isTokenValid(token));
}
@Test
void isTokenValid_Failure_garbage() {
assertFalse(jwtService.isTokenValid("not-a-jwt"));
}
@Test
void isTokenValid_Failure_wrongKey() throws Exception {
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String token = Jwts.builder()
.subject("test@example.com")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(other.getPrivate(), Jwts.SIG.EdDSA)
.compact();
assertFalse(jwtService.isTokenValid(token));
}
private String signTicket(String type, String profileUserId, long expiresInMs) {
return Jwts.builder()
.subject("public-profile")
.claim("typ", type)
.claim("profileUserId", profileUserId)
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
.signWith(signingKey, Jwts.SIG.EdDSA).compact();
}
@Test
void verifyProfileTicket_ReturnsTheUser() {
assertEquals(Optional.of(42L), jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
}
@Test
void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception {
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", "a@b.de", null)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "42", -1_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "not-a-number", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage"));
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
String foreign = Jwts.builder().claim("typ", "public-profile").claim("profileUserId", "42")
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign));
}
@Test
void isTokenValid_ProfileTicketIsNoLogin() {
assertFalse(jwtService.isTokenValid(signTicket("public-profile", "42", 60_000)));
}
}
@@ -0,0 +1,93 @@
package core_service.configuration.security;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import java.util.Date;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Runs with the production security filter (not the dev profile, which injects a dummy user).
*/
@Tag("IntegrationTest")
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class PublicEndpointsTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
private static final KeyPair KEYS = generateKeys();
private static KeyPair generateKeys() {
try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
registry.add("app.steam.api-key", () -> "test-key");
}
@Autowired
private MockMvc mockMvc;
@Test
void healthIsPublicWithoutDetails() throws Exception {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"))
.andExpect(jsonPath("$.components").doesNotExist());
}
@ParameterizedTest
@ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198000000001", "/internal/anything"})
void endpointIsNotPublic(String path) throws Exception {
int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus();
assertTrue(Set.of(401, 403).contains(status), path + " answered " + status);
}
@Test
void profileTicketIsNoLogin() throws Exception {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
.claim("userId", "1").expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket))
.andReturn().getResponse().getStatus();
assertTrue(Set.of(401, 403).contains(status), "ticket as login answered " + status);
}
@Test
void publicProfileEndpointIsPublic() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
}
}
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more