Public Access
Compare commits
29
Commits
1044e53978
...
0a5186782f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5186782f | ||
|
|
9ad4c4b1bd | ||
|
|
ba75c00301 | ||
|
|
00a7991380 | ||
|
|
2f2f8a2918 | ||
|
|
ea1e1e9aec | ||
|
|
ba1f1e6fe1 | ||
|
|
afa2a7ea75 | ||
|
|
0482fb0c88 | ||
|
|
474a5dd570 | ||
|
|
3351ef75f0 | ||
|
|
0ac93394d9 | ||
|
|
b46e2badbd | ||
|
|
d96e682511 | ||
|
|
50f5f26157 | ||
|
|
8de138487e | ||
|
|
aaa3345651 | ||
|
|
dbea8922d5 | ||
|
|
b7efe27a54 | ||
|
|
32339e2ff5 | ||
|
|
1eb445e7a0 | ||
|
|
6b91463c4a | ||
|
|
2f3507b57b | ||
|
|
ac97442312 | ||
|
|
1b08e7902c | ||
|
|
be9b70c541 | ||
|
|
1dc2efba01 | ||
|
|
a1b11882de | ||
|
|
5881dd0613 |
No files matched your search
@@ -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
|
||||
@@ -31,3 +31,10 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Local secrets ###
|
||||
.env
|
||||
|
||||
### Local dev config, never committed ###
|
||||
application-dev.yml
|
||||
application-dev.yaml
|
||||
+3
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
@@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.0-M4</version>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>eu.andreasdahm.tbp</groupId>
|
||||
@@ -16,7 +16,8 @@
|
||||
<java.version>25</java.version>
|
||||
<jwt.version>0.13.0</jwt.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>
|
||||
<dependencies>
|
||||
<!-- Spring -->
|
||||
@@ -40,10 +41,17 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<!-- Rest Caching -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</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 -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
@@ -75,6 +83,8 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Observability -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
@@ -84,6 +94,14 @@
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- testing -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-docker-compose</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
@@ -99,11 +117,48 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
@@ -111,6 +166,7 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
|
||||
@@ -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
|
||||
public class CoreServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
static void main(String[] 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.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;
|
||||
@@ -17,7 +18,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
@Component
|
||||
@Component("jwtAuthenticationFilter")
|
||||
@Profile("!dev")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
@@ -47,10 +49,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
try {
|
||||
final String userEmail = jwtService.extractEmail(jwt);
|
||||
final String userId = jwtService.extractUserId(jwt);
|
||||
final Long steamId = jwtService.extractSteamId(jwt);
|
||||
|
||||
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(
|
||||
principal,
|
||||
null,
|
||||
@@ -66,3 +69,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ import org.springframework.context.annotation.Configuration;
|
||||
@ConfigurationProperties(prefix = "security.jwt")
|
||||
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;
|
||||
|
||||
import core_service.shared.security.UserPrincipal;
|
||||
import core_service.shared.security.ProfileTicketVerifier;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
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
|
||||
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 PublicKey publicKey;
|
||||
|
||||
public JwtService(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
|
||||
}
|
||||
|
||||
public String extractEmail(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload()
|
||||
@@ -28,28 +45,61 @@ public class JwtService {
|
||||
|
||||
public String extractUserId(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload()
|
||||
.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) {
|
||||
try {
|
||||
Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
var claims = Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token);
|
||||
return true;
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
return claims.get(TYPE_CLAIM) == null;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
String secretKey = jwtConfig.getSecret();
|
||||
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
@Override
|
||||
public Optional<Long> verifyProfileTicket(String ticket) {
|
||||
try {
|
||||
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;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
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.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private static final String[] WHITE_LIST_URLS = {
|
||||
"/internal/**", // Allow internal service communication
|
||||
"/actuator/health",
|
||||
"/actuator/prometheus",
|
||||
"/error"
|
||||
"/actuator/health", // Docker health check; details are off
|
||||
"/error",
|
||||
"/api/stats/public",
|
||||
"/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}")
|
||||
private String[] allowedOrigins;
|
||||
@@ -49,7 +54,7 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.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;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface GameAchievementSchemaMetaRepository extends JpaRepository<GameAchievementSchemaMeta, Long> {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.GameAchievementSchema;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface GameAchievementSchemaRepository extends JpaRepository<GameAchievementSchema, GameAchievementSchemaId> {
|
||||
|
||||
List<GameAchievementSchema> findByAppid(Long appid);
|
||||
|
||||
void deleteByAppid(Long appid);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserGameAchievementMetaRepository extends JpaRepository<UserGameAchievementMeta, UserGameId> {
|
||||
|
||||
Optional<UserGameAchievementMeta> findByUserIdAndAppid(Long userId, Long appid);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.UserGameAchievement;
|
||||
import core_service.modules.library.domain.UserGameAchievementId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
|
||||
|
||||
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
|
||||
|
||||
void deleteByUserIdAndAppid(Long userId, Long appid);
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
) {}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package core_service.modules.steam_adapter.api.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record SteamPlayerAchievementsResponse(
|
||||
@JsonProperty("playerstats") PlayerStats playerstats
|
||||
) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PlayerStats(
|
||||
@JsonProperty("steamID") String steamId,
|
||||
@JsonProperty("gameName") String gameName,
|
||||
@JsonProperty("achievements") List<Achievement> achievements,
|
||||
@JsonProperty("success") Boolean success,
|
||||
@JsonProperty("error") String error
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Achievement(
|
||||
@JsonProperty("apiname") String apiName,
|
||||
@JsonProperty("achieved") Integer achieved,
|
||||
@JsonProperty("unlocktime") Long unlockTime
|
||||
) {}
|
||||
}
|
||||
+15
@@ -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
|
||||
) {}
|
||||
}
|
||||
+9
@@ -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
|
||||
) {
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package core_service.modules.steam_adapter.api.dto.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record SteamSchemaForGameResponse(
|
||||
@JsonProperty("game") Game game
|
||||
) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Game(
|
||||
@JsonProperty("gameName") String gameName,
|
||||
@JsonProperty("gameVersion") String gameVersion,
|
||||
@JsonProperty("availableGameStats") AvailableGameStats availableGameStats
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record AvailableGameStats(
|
||||
@JsonProperty("achievements") List<Achievement> achievements
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Achievement(
|
||||
@JsonProperty("name") String name,
|
||||
@JsonProperty("displayName") String displayName,
|
||||
@JsonProperty("description") String description,
|
||||
@JsonProperty("hidden") Integer hidden,
|
||||
@JsonProperty("icon") String icon,
|
||||
@JsonProperty("icongray") String iconGray
|
||||
) {}
|
||||
}
|
||||
+36
@@ -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
|
||||
) {}
|
||||
}
|
||||
+26
@@ -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(
|
||||
String id,
|
||||
String email
|
||||
String email,
|
||||
Long steamId
|
||||
) implements Principal {
|
||||
|
||||
@Override
|
||||
@@ -16,3 +17,4 @@ public record UserPrincipal(
|
||||
return Long.parseLong(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,35 +3,79 @@ spring:
|
||||
name: tbp-core-service
|
||||
profiles:
|
||||
active: prod
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST}:5432/tbp_core
|
||||
username: ${DB_USER}
|
||||
password: ${DB_PASS}
|
||||
jackson:
|
||||
deserialization:
|
||||
accept-float-as-int: false
|
||||
jpa:
|
||||
open-in-view: true
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
mvc:
|
||||
apiversion:
|
||||
use:
|
||||
query-parameter: version
|
||||
default: "1.0"
|
||||
supported: 1.0, 2,0
|
||||
|
||||
server:
|
||||
port: 8081
|
||||
port: 8090
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health, prometheus, metrics
|
||||
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
|
||||
public-key: ${JWT_PUBLIC_KEY}
|
||||
app:
|
||||
cors:
|
||||
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:
|
||||
api-key: ${INTERNAL_API_KEY}
|
||||
resilience4j:
|
||||
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
|
||||
@@ -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
Reference in new issue
Block a user