From 06210f22dc27b455b23083e2510fcbd9eb269698 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 3 Apr 2026 10:45:20 +0800 Subject: [PATCH 001/117] feat: initialize project structure with Gradle and Spring Boot setup --- .gitattributes | 3 + .gitignore | 37 +++ build.gradle.kts | 64 +++++ gradle.properties | 1 + gradle/libs.versions.toml | 74 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle.kts | 14 + .../DeltaForceGuideApplication.java | 13 + src/main/resources/application.yaml | 3 + .../DeltaForceGuideApplicationTests.java | 13 + 13 files changed, 574 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..17ddcac --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + java + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependency.management) +} + +val artefactVersion: String by project + +group = "com.onixbyte.helix" +version = artefactVersion + +tasks.withType { + options.encoding = "UTF-8" +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencies { + implementation(libs.jspecify.core) + implementation(platform(libs.aws.sdk.bom)) + implementation(libs.aws.sdk.s3) + implementation(libs.commons.io) + implementation(libs.commons.lang) + implementation(libs.commons.collections) + implementation(libs.hypersistence.core) + implementation(platform(libs.onixbyte.versionCatalogue)) + implementation(libs.onixbyte.tuple) + implementation(libs.onixbyte.commonToolbox) + implementation(libs.onixbyte.mathToolbox) + implementation(libs.onixbyte.identityGenerator) + implementation(libs.onixbyte.captcha) + implementation(libs.onixbyte.regions) + implementation(libs.jwt.core) + implementation(libs.spring.boot.configurationProcessor) + implementation(libs.spring.boot.actuator) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.webFlux) + implementation(libs.spring.boot.starter.validation) + implementation(libs.spring.boot.starter.redis) + implementation(libs.spring.boot.starter.cache) + implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.jpa) + implementation(libs.mybatis.starter.core) + implementation(libs.flyway.core) + implementation(libs.flyway.postgresql) + implementation(libs.jackson.jsr310) + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.reactor.test) + testImplementation(libs.spring.security.test) + testImplementation(libs.mybatis.starter.test) + runtimeOnly(libs.postgres.driver) + testRuntimeOnly(libs.h2.database) + testRuntimeOnly(libs.junit.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cb94965 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +artefactVersion = 1.0.0 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..db80386 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,74 @@ +[versions] +jspecifyVersion = "1.0.0" +javaJwtVersion = "4.5.1" +postgresDriverVersion = "42.7.9" +h2Version = "2.2.224" +springBootVersion = "3.5.13" +springSecurityVersion = "6.5.2" +reactorVersion = "3.7.8" +junitPlatformVersion = "1.12.2" +onixbyteVersion = "3.3.0" +onixbyteCaptcha = "1.1.0" +onixbyteRegions = "2025.12.0" +awsSdkVersion = "2.25.48" +commonsIoVersion = "2.16.1" +commonsCollections = "4.5.0" +commonsLangVersion = "3.20.0" +mybatisVersion = "3.0.5" +jacksonVersion = "2.19.2" +hypersistenceVersion = "3.14.0" +springDependencyManagementVersion = "1.1.7" + +[libraries] +# General Utilities +jspecify-core = { group = "org.jspecify", name = "jspecify", version.ref = "jspecifyVersion" } +commons-io = { group = "commons-io", name = "commons-io", version.ref = "commonsIoVersion" } +commons-collections = { group = "org.apache.commons", name = "commons-collections4", version.ref = "commonsCollections" } +commons-lang = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commonsLangVersion" } +jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jacksonVersion" } + +# Onixbyte Ecosystem +onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" } +onixbyte-tuple = { group = "com.onixbyte", name = "tuple" } +onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" } +onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" } +onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" } +onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" } +onixbyte-regions = { group = "com.onixbyte", name = "regions4j", version.ref = "onixbyteRegions" } + +# Persistence & Database +mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter", version.ref = "mybatisVersion" } +spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" } +hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" } +postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" } +h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" } +spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" } +flyway-core = { group = "org.flywaydb", name = "flyway-core" } +flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" } + +# Spring Boot Core & Web +spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } +spring-boot-starter-webFlux = { group = "org.springframework.boot", name = "spring-boot-starter-webflux" } +spring-boot-starter-validation = { group = "org.springframework.boot", name = "spring-boot-starter-validation" } +spring-boot-starter-cache = { group = "org.springframework.boot", name = "spring-boot-starter-cache" } +spring-boot-configurationProcessor = { group = "org.springframework.boot", name = "spring-boot-configuration-processor" } +spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator" } + +# Security & Auth +spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security", version.ref = "springBootVersion" } +jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" } + +# Cloud Services +aws-sdk-bom = { group = "software.amazon.awssdk", name = "bom", version.ref = "awsSdkVersion" } +aws-sdk-s3 = { group = "software.amazon.awssdk", name = "s3" } + +# Testing +spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "springBootVersion" } +spring-security-test = { group = "org.springframework.security", name = "spring-security-test", version.ref = "springSecurityVersion" } +reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref = "reactorVersion" } +junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" } +mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" } + +[plugins] +spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" } +spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +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 + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..2b10ea5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,14 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "delta-force-guide-server" diff --git a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java new file mode 100644 index 0000000..4d69512 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DeltaForceGuideApplication { + + public static void main(String[] args) { + SpringApplication.run(DeltaForceGuideApplication.class, args); + } + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..4ca676b --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,3 @@ +spring: + application: + name: delta-force-guide-server diff --git a/src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java b/src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java new file mode 100644 index 0000000..237025f --- /dev/null +++ b/src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DeltaForceGuideApplicationTests { + + @Test + void contextLoads() { + } + +} From 7c249ee4be6ca012fd0e63736ede8ea1a487fc4d Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 3 Apr 2026 10:51:01 +0800 Subject: [PATCH 002/117] ci: add Dockerfile and .dockerignore for containerisation --- .dockerignore | 4 ++++ Dockerfile | 10 ++++++++++ 2 files changed, 14 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..595848f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +delta-force-guide-server.iml +build/ +.idea/ +.gradle \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f5999c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM amazoncorretto:21 AS builder +WORKDIR /app +ADD . . +RUN chmod +x ./gradlew && \ + ./gradlew build -x test + +FROM amazoncorretto:21-alpine AS runner +WORKDIR /app +COPY --from=builder /app/build/libs/delta-force-guide-server-1.0.0.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] From 8053bbb6b6a298bff6abd352f69b12338e0d7407 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 3 Apr 2026 14:29:04 +0800 Subject: [PATCH 003/117] feat: add Firearm and Modification entities with database schema --- .gitignore | 171 +++++++++++++++--- build.gradle.kts | 5 +- gradle/libs.versions.toml | 2 + .../domain/entity/Firearm.java | 96 ++++++++++ .../domain/entity/Modification.java | 120 ++++++++++++ src/main/resources/application.yaml | 45 ++++- src/main/resources/db/migration/V2__init.sql | 34 ++++ 7 files changed, 441 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java create mode 100644 src/main/resources/db/migration/V2__init.sql diff --git a/.gitignore b/.gitignore index c2065bc..329367c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,150 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ +### macOS +# General +.DS_Store +.AppleDouble +.LSOverride -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ +# Thumbnails +._* -### IntelliJ IDEA ### +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# Metadata left by Dolphin file manager, which comes with KDE Plasma +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# Log files created by default by the nohup command +nohup.out + +### Windows + +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### JetBrains IDE +# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff .idea -*.iws + +# Gradle and Maven with auto-import *.iml *.ipr + +# File-based project format +*.iws + +# IntelliJ out/ -!**/src/main/**/out/ -!**/src/test/**/out/ -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ +# JIRA plugin +atlassian-ide-plugin.xml -### VS Code ### -.vscode/ +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based HTTP Client +http-client.private.env.json + +### Server +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +*.jar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Config +config/*.yml +config/*.yaml +config/*.properties + +# Tests +test/ + +### Gradle +.gradle +**/build/ +!**/src/**/build/ + +!gradle/ +!gradle/wrapper +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties +gradle-app.setting +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath diff --git a/build.gradle.kts b/build.gradle.kts index 17ddcac..90f0258 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,13 +48,14 @@ dependencies { implementation(libs.spring.boot.starter.jpa) implementation(libs.mybatis.starter.core) implementation(libs.flyway.core) - implementation(libs.flyway.postgresql) + implementation(libs.flyway.mysql) implementation(libs.jackson.jsr310) testImplementation(libs.spring.boot.starter.test) testImplementation(libs.reactor.test) testImplementation(libs.spring.security.test) testImplementation(libs.mybatis.starter.test) - runtimeOnly(libs.postgres.driver) + // runtimeOnly(libs.postgres.driver) + runtimeOnly(libs.mysql.driver) testRuntimeOnly(libs.h2.database) testRuntimeOnly(libs.junit.launcher) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db80386..b0429e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,10 +41,12 @@ mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spri spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" } hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" } postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" } +mysql-driver = { group = "com.mysql", name = "mysql-connector-j" } h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" } spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" } flyway-core = { group = "org.flywaydb", name = "flyway-core" } flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" } +flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" } # Spring Boot Core & Web spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java new file mode 100644 index 0000000..478cc66 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java @@ -0,0 +1,96 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "firearm") +public class Firearm { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 64) + private String name; + + @Column(name = "type", nullable = false) + private Integer type; + + @Column(name = "level", nullable = false) + private Integer level; + + @Column(name = "review", columnDefinition = "TEXT") + private String review; + + @OneToMany(mappedBy = "firearm", cascade = CascadeType.ALL, orphanRemoval = true) + private List modifications = new ArrayList<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getType() { + return type; + } + + public void setType(Integer type) { + this.type = type; + } + + public Integer getLevel() { + return level; + } + + public void setLevel(Integer level) { + this.level = level; + } + + public String getReview() { + return review; + } + + public void setReview(String review) { + this.review = review; + } + + public List getModifications() { + return modifications; + } + + public void setModifications(List modifications) { + this.modifications = modifications; + } + + public void addModification(Modification modification) { + this.modifications.add(modification); + modification.setFirearm(this); + } + + public void removeModification(Modification modification) { + this.modifications.remove(modification); + modification.setFirearm(null); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java new file mode 100644 index 0000000..6f29bd5 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java @@ -0,0 +1,120 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.hibernate.annotations.Type; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table( + name = "modification", + indexes = { + @Index(name = "idx_modification_firearm_id", columnList = "firearm_id") + } +) +public class Modification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "firearm_id", nullable = false, foreignKey = @ForeignKey(name = "fk_modification_firearm")) + private Firearm firearm; + + @Column(name = "name", nullable = false, length = 64) + private String name; + + @Column(name = "code", nullable = false, length = 64) + private String code; + + @Type(JsonType.class) + @Column(name = "tags", columnDefinition = "json") + private List tags = new ArrayList<>(); + + @Column(name = "note", columnDefinition = "TEXT") + private String note; + + @Column(name = "author", length = 64) + private String author; + + @Column(name = "video_url", length = 512) + private String videoUrl; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Firearm getFirearm() { + return firearm; + } + + public void setFirearm(Firearm firearm) { + this.firearm = firearm; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getVideoUrl() { + return videoUrl; + } + + public void setVideoUrl(String videoUrl) { + this.videoUrl = videoUrl; + } +} + diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4ca676b..65b087c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,46 @@ spring: application: - name: delta-force-guide-server + name: delta-force-guide + cache: + type: redis + redis: + time-to-live: PT2H + data: + redis: + repositories: + # Disable redis repositories + enabled: false + jta: + # Disable JTA support + enabled: false + jpa: + properties: + hibernate: + transaction: + jta: + # No need to use distributed transaction manager for 1 datasource. + platform: org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform + hibernate: + ddl-auto: none + open-in-view: false + datasource: + hikari: + minimum-idle: 1 + maximum-pool-size: 10 + flyway: + enabled: true + baseline-on-migrate: true + +mybatis: + configuration: + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + map-underscore-to-camel-case: true + type-aliases-package: com.onixbyte.deltaforceguide.domain.entity + type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler + mapper-locations: classpath:/mapper/*.xml + +logging: + level: + org.hibernate: + orm.connections.pooling: off + diff --git a/src/main/resources/db/migration/V2__init.sql b/src/main/resources/db/migration/V2__init.sql new file mode 100644 index 0000000..332f98e --- /dev/null +++ b/src/main/resources/db/migration/V2__init.sql @@ -0,0 +1,34 @@ +DROP TABLE IF EXISTS firearm CASCADE; +DROP TABLE IF EXISTS modification CASCADE; + + +CREATE TABLE IF NOT EXISTS firearm +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + type INT NOT NULL, + level INT NOT NULL, + review TEXT NULL +); + + +CREATE TABLE IF NOT EXISTS modification +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + firearm_id BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + tags JSON NULL, + note TEXT NULL, + author VARCHAR(64) NULL, + video_url VARCHAR(512) NULL, + CONSTRAINT fk_modification_firearm + FOREIGN KEY (firearm_id) + REFERENCES firearm (id) + ON DELETE CASCADE + ON UPDATE RESTRICT +); + +CREATE INDEX idx_modification_firearm_id ON modification (firearm_id); + + From 2616e70062d9a9c7a780541b6da2565fb01bbd81 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 3 Apr 2026 14:53:59 +0800 Subject: [PATCH 004/117] feat: add configurations and utility classes --- build.gradle.kts | 3 - .../deltaforceguide/config/CacheConfig.java | 102 ++++++++++++++++++ .../deltaforceguide/config/CorsConfig.java | 43 ++++++++ .../deltaforceguide/config/JacksonConfig.java | 20 ++++ .../deltaforceguide/config/MyBatisConfig.java | 10 ++ .../config/SpringDataConfig.java | 10 ++ .../properties/CorsProperties.java | 24 +++++ .../shared/DateTimeFormatters.java | 46 ++++++++ .../shared/JacksonModules.java | 34 ++++++ .../shared/JacksonRedisSerialiser.java | 22 ++++ 10 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/CacheConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/shared/DateTimeFormatters.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java diff --git a/build.gradle.kts b/build.gradle.kts index 90f0258..d7b6f12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,7 +44,6 @@ dependencies { implementation(libs.spring.boot.starter.validation) implementation(libs.spring.boot.starter.redis) implementation(libs.spring.boot.starter.cache) - implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.jpa) implementation(libs.mybatis.starter.core) implementation(libs.flyway.core) @@ -52,9 +51,7 @@ dependencies { implementation(libs.jackson.jsr310) testImplementation(libs.spring.boot.starter.test) testImplementation(libs.reactor.test) - testImplementation(libs.spring.security.test) testImplementation(libs.mybatis.starter.test) - // runtimeOnly(libs.postgres.driver) runtimeOnly(libs.mysql.driver) testRuntimeOnly(libs.h2.database) testRuntimeOnly(libs.junit.launcher) diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/CacheConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/CacheConfig.java new file mode 100644 index 0000000..0502c4a --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/CacheConfig.java @@ -0,0 +1,102 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.shared.JacksonRedisSerialiser; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; + +import java.time.Duration; + +/** + * Configuration class for Redis-based caching components. + *

+ * This configuration class provides beans for Redis cache management and template operations + * within the Helix application. It configures custom serialisation strategies using + * {@link GenericJackson2JsonRedisSerializer} for values and string serialisation for keys, + * ensuring optimal performance and compatibility with JSON-based data structures. + *

+ * The configuration includes: + *

    + *
  • Custom {@link RedisCacheManager} with JSON serialisation support
  • + *
  • Configured {@link RedisTemplate} for direct Redis operations
  • + *
+ * + * @author zihluwang + * @see RedisCacheManager + * @see RedisTemplate + * @see GenericJackson2JsonRedisSerializer + * @since 1.0.0 + */ +@Configuration +@EnableCaching +public class CacheConfig { + + /** + * Creates a custom Redis cache manager with JSON serialisation support. + *

+ * This method configures a {@link RedisCacheManager} that uses string serialisation for cache + * keys and {@link GenericJackson2JsonRedisSerializer} for cache values. This setup ensures that + * complex objects can be stored and retrieved from Redis cache whilst maintaining readability + * and compatibility with JSON-based systems. + * + * @param connectionFactory the Redis connection factory used to establish connections + * @return a configured {@link RedisCacheManager} with custom serialisation settings + * @see RedisCacheManager + * @see GenericJackson2JsonRedisSerializer + * @see RedisSerializationContext + */ + @Bean + public RedisCacheManager cacheManager( + RedisConnectionFactory connectionFactory + ) { + var _keySerializer = RedisSerializer.string(); + + var cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(_keySerializer)) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(JacksonRedisSerialiser.INSTANCE)) + .entryTtl(Duration.ofMinutes(90L)); + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(connectionFactory) + .cacheDefaults(cacheConfiguration) + .build(); + } + + /** + * Creates a Redis template for direct Redis operations with custom serialisation. + *

+ * This method configures a {@link RedisTemplate} that uses string serialisation for keys + * and {@link GenericJackson2JsonRedisSerializer} for values. This template provides low-level + * access to Redis operations whilst ensuring consistent serialisation strategies across + * the application. + *

+ * The template is fully configured and ready for use after bean creation. + * + * @param connectionFactory the Redis connection factory used to establish connections + * @return a fully configured {@link RedisTemplate} for Redis operations + * @see RedisTemplate + * @see GenericJackson2JsonRedisSerializer + * @see RedisSerializer + */ + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory + ) { + var redisTemplate = new RedisTemplate(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setValueSerializer(JacksonRedisSerialiser.INSTANCE); + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java new file mode 100644 index 0000000..565bf69 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java @@ -0,0 +1,43 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.properties.CorsProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Optional; +import java.util.stream.Stream; + +@Configuration +@EnableConfigurationProperties({CorsProperties.class}) +public class CorsConfig implements WebMvcConfigurer { + + private final CorsProperties properties; + + public CorsConfig(CorsProperties properties) { + this.properties = properties; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(properties.allowedOrigins()) + .allowedHeaders(properties.allowedHeaders()) + .allowedMethods(toHttpMethodNames(properties.allowedMethods())) + .allowCredentials(properties.allowCredentials()) + .maxAge(properties.maxAge().toSeconds()) + .exposedHeaders(properties.exposedHeaders()); + } + + private static String[] toHttpMethodNames(HttpMethod[] methods) { + return Optional.ofNullable(methods) + .stream() + .flatMap(Stream::of) + .map(HttpMethod::name) + .toList() + .toArray(String[]::new); + } + +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java new file mode 100644 index 0000000..a64a1ea --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package com.onixbyte.deltaforceguide.config; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.onixbyte.deltaforceguide.shared.JacksonModules; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomiser() { + return (builder) -> { + builder.modules(JacksonModules.DATE_TIME_MODULE); + builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + }; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java new file mode 100644 index 0000000..61acbfc --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java @@ -0,0 +1,10 @@ +package com.onixbyte.deltaforceguide.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"}) +public class MyBatisConfig { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java new file mode 100644 index 0000000..44e3a4c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java @@ -0,0 +1,10 @@ +package com.onixbyte.deltaforceguide.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"}) +public class SpringDataConfig { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java new file mode 100644 index 0000000..ff064d5 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java @@ -0,0 +1,24 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.http.HttpMethod; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "app.cors") +public record CorsProperties( + @DefaultValue({"Content-Type", "Authorization"}) + String[] allowedHeaders, + @DefaultValue({"GET", "POST", "PUT", "PATCH", "DELETE"}) + HttpMethod[] allowedMethods, + String[] allowedOrigins, + boolean allowCredentials, + boolean allowPrivateNetwork, + @DefaultValue("PT2H") + Duration maxAge, + @DefaultValue({"Content-Type", "Authorization"}) + String[] exposedHeaders +) { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/DateTimeFormatters.java b/src/main/java/com/onixbyte/deltaforceguide/shared/DateTimeFormatters.java new file mode 100644 index 0000000..134408b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/DateTimeFormatters.java @@ -0,0 +1,46 @@ +package com.onixbyte.deltaforceguide.shared; + +import java.time.format.DateTimeFormatter; + +/** + * Utility class providing predefined {@link DateTimeFormatter} instances for common date and + * time patterns. These formatters can be used to parse and format {@code java.time} objects + * consistently throughout an application. + * + * @author zihluwang + */ +public class DateTimeFormatters { + + /** + * A {@link DateTimeFormatter} for formatting and parsing full date and time with seconds, using + * the pattern "yyyy-MM-dd HH:mm:ss". + *

+ * Example: "{@code 2023-10-27 15:30:45}" + */ + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * A {@link DateTimeFormatter} for formatting and parsing dates only, using the + * pattern "yyyy-MM-dd". + *

+ * Example: "{@code 2023-10-27}" + */ + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * A {@link DateTimeFormatter} for formatting and parsing times only, using the + * pattern "HH:mm:ss". + *

+ * Example: "{@code 15:30:45}" + */ + public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** + * A {@link DateTimeFormatter} for formatting and parsing year and month only, using the + * pattern "yyyy-MM". + *

+ * Example: "{@code 2023-10}" + */ + public static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java new file mode 100644 index 0000000..36332aa --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java @@ -0,0 +1,34 @@ +package com.onixbyte.deltaforceguide.shared; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +public class JacksonModules { + + public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule(); + + private static SimpleModule initialiseDateTimeModule() { + var javaTimeModule = new JavaTimeModule(); + javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatters.DATE_TIME_FORMATTER)); + javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatters.DATE_TIME_FORMATTER)); + + javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatters.DATE_FORMATTER)); + javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatters.DATE_FORMATTER)); + + javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatters.TIME_FORMATTER)); + javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatters.TIME_FORMATTER)); + + return javaTimeModule; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java new file mode 100644 index 0000000..5a300ba --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java @@ -0,0 +1,22 @@ +package com.onixbyte.deltaforceguide.shared; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; + +public class JacksonRedisSerialiser { + + public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer(); + + private static GenericJackson2JsonRedisSerializer initialiseSerializer() { + var serializer = new GenericJackson2JsonRedisSerializer(); + + serializer.configure((configurer) -> { + configurer.registerModule(new JavaTimeModule()); + configurer.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + }); + + return serializer; + } +} + From 20c2da10ab6c4f9f11d5d9d07646952cdd7cce67 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 3 Apr 2026 15:38:24 +0800 Subject: [PATCH 005/117] feat: enhance Firearm entity and add query services with controllers --- build.gradle.kts | 1 + .../deltaforceguide/config/CorsConfig.java | 10 +++-- .../controller/FirearmQueryController.java | 43 +++++++++++++++++++ .../ModificationQueryController.java | 43 +++++++++++++++++++ .../converter/FirearmTypeConverter.java | 21 +++++++++ .../domain/dto/FirearmResponse.java | 23 ++++++++++ .../domain/dto/ModificationResponse.java | 30 +++++++++++++ .../domain/dto/PageResponse.java | 24 +++++++++++ .../domain/entity/Firearm.java | 16 ++++--- .../enumeration/FirearmType.java | 37 ++++++++++++++++ .../repository/FirearmRepository.java | 8 ++++ .../repository/ModificationRepository.java | 21 +++++++++ .../service/FirearmQueryService.java | 36 ++++++++++++++++ .../service/ModificationQueryService.java | 36 ++++++++++++++++ .../db/migration/V3__alter_firearm_level.sql | 2 + .../DeltaForceGuideApplicationTests.java | 13 ------ 16 files changed, 342 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/FirearmQueryController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/ModificationQueryController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/FirearmQueryService.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/ModificationQueryService.java create mode 100644 src/main/resources/db/migration/V3__alter_firearm_level.sql delete mode 100644 src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java diff --git a/build.gradle.kts b/build.gradle.kts index d7b6f12..64a0cbc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(libs.flyway.core) implementation(libs.flyway.mysql) implementation(libs.jackson.jsr310) + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16") testImplementation(libs.spring.boot.starter.test) testImplementation(libs.reactor.test) testImplementation(libs.mybatis.starter.test) diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java index 565bf69..916ff57 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java @@ -23,12 +23,16 @@ public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins(properties.allowedOrigins()) - .allowedHeaders(properties.allowedHeaders()) + .allowedOrigins(toSafeArray(properties.allowedOrigins())) + .allowedHeaders(toSafeArray(properties.allowedHeaders())) .allowedMethods(toHttpMethodNames(properties.allowedMethods())) .allowCredentials(properties.allowCredentials()) .maxAge(properties.maxAge().toSeconds()) - .exposedHeaders(properties.exposedHeaders()); + .exposedHeaders(toSafeArray(properties.exposedHeaders())); + } + + private static String[] toSafeArray(String[] values) { + return values == null ? new String[0] : values; } private static String[] toHttpMethodNames(HttpMethod[] methods) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmQueryController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmQueryController.java new file mode 100644 index 0000000..b4570b1 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmQueryController.java @@ -0,0 +1,43 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.service.FirearmQueryService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/firearms") +public class FirearmQueryController { + + private final FirearmQueryService firearmQueryService; + + public FirearmQueryController(FirearmQueryService firearmQueryService) { + this.firearmQueryService = firearmQueryService; + } + + @GetMapping + public PageResponse pageQuery( + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "DESC") Sort.Direction direction + ) { + return firearmQueryService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); + } + + @GetMapping("/{id}") + public FirearmResponse queryById(@PathVariable Long id) { + return firearmQueryService.queryById(id); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationQueryController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationQueryController.java new file mode 100644 index 0000000..888b964 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationQueryController.java @@ -0,0 +1,43 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.service.ModificationQueryService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/modifications") +public class ModificationQueryController { + + private final ModificationQueryService modificationQueryService; + + public ModificationQueryController(ModificationQueryService modificationQueryService) { + this.modificationQueryService = modificationQueryService; + } + + @GetMapping + public PageResponse pageQuery( + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "DESC") Sort.Direction direction + ) { + return modificationQueryService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); + } + + @GetMapping("/{id}") + public ModificationResponse queryById(@PathVariable Long id) { + return modificationQueryService.queryById(id); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java new file mode 100644 index 0000000..f6f46f8 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.domain.converter; + +import com.onixbyte.deltaforceguide.enumeration.FirearmType; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class FirearmTypeConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(FirearmType attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public FirearmType convertToEntityAttribute(Integer dbData) { + return FirearmType.fromCode(dbData); + } +} + + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java new file mode 100644 index 0000000..fc62d43 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java @@ -0,0 +1,23 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.Firearm; +import com.onixbyte.deltaforceguide.enumeration.FirearmType; + +public record FirearmResponse( + Long id, + String name, + FirearmType type, + String level, + String review +) { + public static FirearmResponse from(Firearm firearm) { + return new FirearmResponse( + firearm.getId(), + firearm.getName(), + firearm.getType(), + firearm.getLevel(), + firearm.getReview() + ); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java new file mode 100644 index 0000000..7eb6384 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java @@ -0,0 +1,30 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.Modification; + +import java.util.List; + +public record ModificationResponse( + Long id, + Long firearmId, + String name, + String code, + List tags, + String note, + String author, + String videoUrl +) { + public static ModificationResponse from(Modification modification) { + return new ModificationResponse( + modification.getId(), + modification.getFirearm().getId(), + modification.getName(), + modification.getCode(), + modification.getTags(), + modification.getNote(), + modification.getAuthor(), + modification.getVideoUrl() + ); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java new file mode 100644 index 0000000..0eefb62 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java @@ -0,0 +1,24 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record PageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + public static PageResponse from(Page source) { + return new PageResponse<>( + source.getContent(), + source.getNumber(), + source.getSize(), + source.getTotalElements(), + source.getTotalPages() + ); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java index 478cc66..9db6dc4 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java @@ -1,7 +1,10 @@ package com.onixbyte.deltaforceguide.domain.entity; +import com.onixbyte.deltaforceguide.domain.converter.FirearmTypeConverter; +import com.onixbyte.deltaforceguide.enumeration.FirearmType; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -24,10 +27,11 @@ public class Firearm { private String name; @Column(name = "type", nullable = false) - private Integer type; + @Convert(converter = FirearmTypeConverter.class) + private FirearmType type; @Column(name = "level", nullable = false) - private Integer level; + private String level; @Column(name = "review", columnDefinition = "TEXT") private String review; @@ -51,19 +55,19 @@ public class Firearm { this.name = name; } - public Integer getType() { + public FirearmType getType() { return type; } - public void setType(Integer type) { + public void setType(FirearmType type) { this.type = type; } - public Integer getLevel() { + public String getLevel() { return level; } - public void setLevel(Integer level) { + public void setLevel(String level) { this.level = level; } diff --git a/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java b/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java new file mode 100644 index 0000000..d4965fa --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java @@ -0,0 +1,37 @@ +package com.onixbyte.deltaforceguide.enumeration; + +public enum FirearmType { + + RIFLE(0), + SUB_MACHINE_GUN(1), + SHOTGUN(2), + LIGHT_MACHINE_GUN(3), + DESIGNATED_MARKSMAN_RIFLE(4), + SNIPER_RIFLE(5), + PISTOL(6), + SPECIAL(7); + + private final int code; + + FirearmType(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static FirearmType fromCode(Integer code) { + if (code == null) { + return null; + } + + for (FirearmType type : values()) { + if (type.code == code) { + return type; + } + } + + throw new IllegalArgumentException("Unknown FirearmType code: " + code); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java new file mode 100644 index 0000000..cb78b5b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -0,0 +1,8 @@ +package com.onixbyte.deltaforceguide.repository; + +import com.onixbyte.deltaforceguide.domain.entity.Firearm; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FirearmRepository extends JpaRepository { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java new file mode 100644 index 0000000..baf5ad4 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.repository; + +import com.onixbyte.deltaforceguide.domain.entity.Modification; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ModificationRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"firearm"}) + Page findAllBy(Pageable pageable); + + @Override + @EntityGraph(attributePaths = {"firearm"}) + Optional findById(Long id); +} + + diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmQueryService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmQueryService.java new file mode 100644 index 0000000..6b07124 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmQueryService.java @@ -0,0 +1,36 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.repository.FirearmRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class FirearmQueryService { + + private final FirearmRepository firearmRepository; + + public FirearmQueryService(FirearmRepository firearmRepository) { + this.firearmRepository = firearmRepository; + } + + @Transactional(readOnly = true) + public PageResponse pageQuery(Pageable pageable) { + return PageResponse.from( + firearmRepository.findAll(pageable) + .map(FirearmResponse::from) + ); + } + + @Transactional(readOnly = true) + public FirearmResponse queryById(Long id) { + return firearmRepository.findById(id) + .map(FirearmResponse::from) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationQueryService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationQueryService.java new file mode 100644 index 0000000..e44e616 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationQueryService.java @@ -0,0 +1,36 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.repository.ModificationRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class ModificationQueryService { + + private final ModificationRepository modificationRepository; + + public ModificationQueryService(ModificationRepository modificationRepository) { + this.modificationRepository = modificationRepository; + } + + @Transactional(readOnly = true) + public PageResponse pageQuery(Pageable pageable) { + return PageResponse.from( + modificationRepository.findAllBy(pageable) + .map(ModificationResponse::from) + ); + } + + @Transactional(readOnly = true) + public ModificationResponse queryById(Long id) { + return modificationRepository.findById(id) + .map(ModificationResponse::from) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); + } +} + diff --git a/src/main/resources/db/migration/V3__alter_firearm_level.sql b/src/main/resources/db/migration/V3__alter_firearm_level.sql new file mode 100644 index 0000000..3d70fc0 --- /dev/null +++ b/src/main/resources/db/migration/V3__alter_firearm_level.sql @@ -0,0 +1,2 @@ +ALTER TABLE firearm + MODIFY level VARCHAR(2) NOT NULL; diff --git a/src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java b/src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java deleted file mode 100644 index 237025f..0000000 --- a/src/test/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.onixbyte.deltaforceguide; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DeltaForceGuideApplicationTests { - - @Test - void contextLoads() { - } - -} From 80dc5170a4d3aa8331b4ca039f4d114dc7a2aa0d Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 3 Apr 2026 17:47:40 +0800 Subject: [PATCH 006/117] refactor: rename query controllers and services for consistency --- ...eryController.java => FirearmController.java} | 16 ++++++++-------- ...ntroller.java => ModificationController.java} | 16 ++++++++-------- .../repository/FirearmRepository.java | 2 ++ .../repository/ModificationRepository.java | 2 ++ ...earmQueryService.java => FirearmService.java} | 4 ++-- ...ueryService.java => ModificationService.java} | 4 ++-- 6 files changed, 24 insertions(+), 20 deletions(-) rename src/main/java/com/onixbyte/deltaforceguide/controller/{FirearmQueryController.java => FirearmController.java} (71%) rename src/main/java/com/onixbyte/deltaforceguide/controller/{ModificationQueryController.java => ModificationController.java} (69%) rename src/main/java/com/onixbyte/deltaforceguide/service/{FirearmQueryService.java => FirearmService.java} (91%) rename src/main/java/com/onixbyte/deltaforceguide/service/{ModificationQueryService.java => ModificationService.java} (90%) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmQueryController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java similarity index 71% rename from src/main/java/com/onixbyte/deltaforceguide/controller/FirearmQueryController.java rename to src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index b4570b1..40b6617 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmQueryController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -2,7 +2,7 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; -import com.onixbyte.deltaforceguide.service.FirearmQueryService; +import com.onixbyte.deltaforceguide.service.FirearmService; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; @@ -16,13 +16,13 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController -@RequestMapping("/api/v1/firearms") -public class FirearmQueryController { +@RequestMapping("/firearms") +public class FirearmController { - private final FirearmQueryService firearmQueryService; + private final FirearmService firearmService; - public FirearmQueryController(FirearmQueryService firearmQueryService) { - this.firearmQueryService = firearmQueryService; + public FirearmController(FirearmService firearmService) { + this.firearmService = firearmService; } @GetMapping @@ -32,12 +32,12 @@ public class FirearmQueryController { @RequestParam(defaultValue = "id") String sortBy, @RequestParam(defaultValue = "DESC") Sort.Direction direction ) { - return firearmQueryService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); + return firearmService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); } @GetMapping("/{id}") public FirearmResponse queryById(@PathVariable Long id) { - return firearmQueryService.queryById(id); + return firearmService.queryById(id); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationQueryController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java similarity index 69% rename from src/main/java/com/onixbyte/deltaforceguide/controller/ModificationQueryController.java rename to src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index 888b964..8029f1f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationQueryController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -2,7 +2,7 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; -import com.onixbyte.deltaforceguide.service.ModificationQueryService; +import com.onixbyte.deltaforceguide.service.ModificationService; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; @@ -16,13 +16,13 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController -@RequestMapping("/api/v1/modifications") -public class ModificationQueryController { +@RequestMapping("/modifications") +public class ModificationController { - private final ModificationQueryService modificationQueryService; + private final ModificationService modificationService; - public ModificationQueryController(ModificationQueryService modificationQueryService) { - this.modificationQueryService = modificationQueryService; + public ModificationController(ModificationService modificationService) { + this.modificationService = modificationService; } @GetMapping @@ -32,12 +32,12 @@ public class ModificationQueryController { @RequestParam(defaultValue = "id") String sortBy, @RequestParam(defaultValue = "DESC") Sort.Direction direction ) { - return modificationQueryService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); + return modificationService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); } @GetMapping("/{id}") public ModificationResponse queryById(@PathVariable Long id) { - return modificationQueryService.queryById(id); + return modificationService.queryById(id); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index cb78b5b..88c38fe 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -2,7 +2,9 @@ package com.onixbyte.deltaforceguide.repository; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository public interface FirearmRepository extends JpaRepository { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index baf5ad4..2fbc122 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -5,9 +5,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; import java.util.Optional; +@Repository public interface ModificationRepository extends JpaRepository { @EntityGraph(attributePaths = {"firearm"}) diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmQueryService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java similarity index 91% rename from src/main/java/com/onixbyte/deltaforceguide/service/FirearmQueryService.java rename to src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java index 6b07124..7506826 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmQueryService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java @@ -10,11 +10,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; @Service -public class FirearmQueryService { +public class FirearmService { private final FirearmRepository firearmRepository; - public FirearmQueryService(FirearmRepository firearmRepository) { + public FirearmService(FirearmRepository firearmRepository) { this.firearmRepository = firearmRepository; } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationQueryService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java similarity index 90% rename from src/main/java/com/onixbyte/deltaforceguide/service/ModificationQueryService.java rename to src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index e44e616..da75ce9 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationQueryService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -10,11 +10,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; @Service -public class ModificationQueryService { +public class ModificationService { private final ModificationRepository modificationRepository; - public ModificationQueryService(ModificationRepository modificationRepository) { + public ModificationService(ModificationRepository modificationRepository) { this.modificationRepository = modificationRepository; } From 33e29f48b97a46e6dc92d7fef526648875fca734 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 6 Apr 2026 14:01:13 +0800 Subject: [PATCH 007/117] feat: add firearm type filtering to page queries in Firearm and Modification services --- .../controller/FirearmController.java | 6 ++++-- .../controller/ModificationController.java | 4 +++- .../repository/FirearmRepository.java | 5 +++++ .../repository/ModificationRepository.java | 3 +++ .../deltaforceguide/service/FirearmService.java | 14 +++++++++----- .../service/ModificationService.java | 13 ++++++++----- 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index 40b6617..f4d7a36 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -2,6 +2,7 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.enumeration.FirearmType; import com.onixbyte.deltaforceguide.service.FirearmService; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -30,9 +31,10 @@ public class FirearmController { @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "DESC") Sort.Direction direction + @RequestParam(defaultValue = "DESC") Sort.Direction direction, + @RequestParam(required = false) FirearmType type ) { - return firearmService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); + return firearmService.pageQuery(type, PageRequest.of(page, size, Sort.by(direction, sortBy))); } @GetMapping("/{id}") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index 8029f1f..d047263 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.service.ModificationService; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.validation.annotation.Validated; @@ -29,10 +30,11 @@ public class ModificationController { public PageResponse pageQuery( @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @RequestParam(required = false) @Positive Long firearmId, @RequestParam(defaultValue = "id") String sortBy, @RequestParam(defaultValue = "DESC") Sort.Direction direction ) { - return modificationService.pageQuery(PageRequest.of(page, size, Sort.by(direction, sortBy))); + return modificationService.pageQuery(firearmId, PageRequest.of(page, size, Sort.by(direction, sortBy))); } @GetMapping("/{id}") diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index 88c38fe..dcf85f8 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -1,10 +1,15 @@ package com.onixbyte.deltaforceguide.repository; import com.onixbyte.deltaforceguide.domain.entity.Firearm; +import com.onixbyte.deltaforceguide.enumeration.FirearmType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface FirearmRepository extends JpaRepository { + + Page findAllByType(FirearmType type, Pageable pageable); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index 2fbc122..f81f73e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -15,6 +15,9 @@ public interface ModificationRepository extends JpaRepository findAllBy(Pageable pageable); + @EntityGraph(attributePaths = {"firearm"}) + Page findAllByFirearm_Id(Long firearmId, Pageable pageable); + @Override @EntityGraph(attributePaths = {"firearm"}) Optional findById(Long id); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java index 7506826..327254c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java @@ -2,7 +2,10 @@ package com.onixbyte.deltaforceguide.service; import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.entity.Firearm; +import com.onixbyte.deltaforceguide.enumeration.FirearmType; import com.onixbyte.deltaforceguide.repository.FirearmRepository; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -19,11 +22,12 @@ public class FirearmService { } @Transactional(readOnly = true) - public PageResponse pageQuery(Pageable pageable) { - return PageResponse.from( - firearmRepository.findAll(pageable) - .map(FirearmResponse::from) - ); + public PageResponse pageQuery(FirearmType type, Pageable pageable) { + Page page = type == null + ? firearmRepository.findAll(pageable) + : firearmRepository.findAllByType(type, pageable); + + return PageResponse.from(page.map(FirearmResponse::from)); } @Transactional(readOnly = true) diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index da75ce9..b1d9cc1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -2,7 +2,9 @@ package com.onixbyte.deltaforceguide.service; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.entity.Modification; import com.onixbyte.deltaforceguide.repository.ModificationRepository; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -19,11 +21,12 @@ public class ModificationService { } @Transactional(readOnly = true) - public PageResponse pageQuery(Pageable pageable) { - return PageResponse.from( - modificationRepository.findAllBy(pageable) - .map(ModificationResponse::from) - ); + public PageResponse pageQuery(Long firearmId, Pageable pageable) { + Page page = firearmId == null + ? modificationRepository.findAllBy(pageable) + : modificationRepository.findAllByFirearm_Id(firearmId, pageable); + + return PageResponse.from(page.map(ModificationResponse::from)); } @Transactional(readOnly = true) From 3f6d40fba76afa4a3128ee4292c55645c2500933 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 6 Apr 2026 21:02:56 +0800 Subject: [PATCH 008/117] feat: migrate database from MySQL to PostgreSQL and update schema --- build.gradle.kts | 4 ++-- src/main/resources/db/migration/V2__init.sql | 8 ++++---- .../resources/db/migration/V3__alter_firearm_level.sql | 2 -- 3 files changed, 6 insertions(+), 8 deletions(-) delete mode 100644 src/main/resources/db/migration/V3__alter_firearm_level.sql diff --git a/build.gradle.kts b/build.gradle.kts index 64a0cbc..8a5dd62 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,13 +47,13 @@ dependencies { implementation(libs.spring.boot.starter.jpa) implementation(libs.mybatis.starter.core) implementation(libs.flyway.core) - implementation(libs.flyway.mysql) + implementation(libs.flyway.postgresql) implementation(libs.jackson.jsr310) implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16") testImplementation(libs.spring.boot.starter.test) testImplementation(libs.reactor.test) testImplementation(libs.mybatis.starter.test) - runtimeOnly(libs.mysql.driver) + runtimeOnly(libs.postgres.driver) testRuntimeOnly(libs.h2.database) testRuntimeOnly(libs.junit.launcher) } diff --git a/src/main/resources/db/migration/V2__init.sql b/src/main/resources/db/migration/V2__init.sql index 332f98e..acbaad1 100644 --- a/src/main/resources/db/migration/V2__init.sql +++ b/src/main/resources/db/migration/V2__init.sql @@ -4,21 +4,21 @@ DROP TABLE IF EXISTS modification CASCADE; CREATE TABLE IF NOT EXISTS firearm ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + id BIGSERIAL NOT NULL PRIMARY KEY, name VARCHAR(64) NOT NULL, type INT NOT NULL, - level INT NOT NULL, + level VARCHAR(10) NOT NULL, review TEXT NULL ); CREATE TABLE IF NOT EXISTS modification ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + id BIGSERIAL NOT NULL PRIMARY KEY, firearm_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, code VARCHAR(64) NOT NULL, - tags JSON NULL, + tags JSONB NULL, note TEXT NULL, author VARCHAR(64) NULL, video_url VARCHAR(512) NULL, diff --git a/src/main/resources/db/migration/V3__alter_firearm_level.sql b/src/main/resources/db/migration/V3__alter_firearm_level.sql deleted file mode 100644 index 3d70fc0..0000000 --- a/src/main/resources/db/migration/V3__alter_firearm_level.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE firearm - MODIFY level VARCHAR(2) NOT NULL; From 1a88cf37bccc6e460172597f56be2d266c549edf Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 6 Apr 2026 21:03:50 +0800 Subject: [PATCH 009/117] chore: update artefact version to 1.1.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index cb94965..b5a5b32 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -artefactVersion = 1.0.0 \ No newline at end of file +artefactVersion = 1.1.0 \ No newline at end of file From a28033ff4c16b4aef0f7887f0827d4b704290e3a Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 7 Apr 2026 11:58:46 +0800 Subject: [PATCH 010/117] feat: add tag filtering to modification queries and implement tag retrieval endpoint --- .../controller/ModificationController.java | 8 +++-- .../controller/TagController.java | 25 ++++++++++++++ .../repository/ModificationRepository.java | 21 ++++++++++-- .../service/ModificationService.java | 34 +++++++++++++++---- 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index d047263..816adda 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -15,6 +15,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Validated @RestController @RequestMapping("/modifications") @@ -32,9 +34,10 @@ public class ModificationController { @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, @RequestParam(required = false) @Positive Long firearmId, @RequestParam(defaultValue = "id") String sortBy, - @RequestParam(defaultValue = "DESC") Sort.Direction direction + @RequestParam(defaultValue = "DESC") Sort.Direction direction, + @RequestParam(required = false) List tags ) { - return modificationService.pageQuery(firearmId, PageRequest.of(page, size, Sort.by(direction, sortBy))); + return modificationService.pageQuery(firearmId, tags, PageRequest.of(page, size, Sort.by(direction, sortBy))); } @GetMapping("/{id}") @@ -42,4 +45,3 @@ public class ModificationController { return modificationService.queryById(id); } } - diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java new file mode 100644 index 0000000..41230aa --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java @@ -0,0 +1,25 @@ +package com.onixbyte.deltaforceguide.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.onixbyte.deltaforceguide.service.ModificationService; + +import java.util.List; + +@RestController +@RequestMapping("/tags") +public class TagController { + + private final ModificationService modificationService; + + public TagController(ModificationService modificationService) { + this.modificationService = modificationService; + } + + @GetMapping + public List getTags(@RequestParam(required = false) Long firearmId) { + return modificationService.findAllTags(firearmId); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index f81f73e..5fd88d4 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -5,8 +5,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -21,6 +24,20 @@ public interface ModificationRepository extends JpaRepository findById(Long id); + + @Query(value = """ + SELECT * FROM modification m + WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) + AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) + """, + countQuery = """ + SELECT count(*) FROM modification m + WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) + AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) + """, + nativeQuery = true) + Page pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable); + + @Query(value = "SELECT DISTINCT jsonb_array_elements_text(cast(tags as jsonb)) FROM modification WHERE (:firearmId IS NULL OR firearm_id = :firearmId)", nativeQuery = true) + List findAllTags(@Param("firearmId") Long firearmId); } - - diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index b1d9cc1..4da45d3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -4,6 +4,8 @@ import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.domain.entity.Modification; import com.onixbyte.deltaforceguide.repository.ModificationRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -11,20 +13,36 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.util.List; + @Service public class ModificationService { private final ModificationRepository modificationRepository; + private final ObjectMapper objectMapper; - public ModificationService(ModificationRepository modificationRepository) { + public ModificationService(ModificationRepository modificationRepository, ObjectMapper objectMapper) { this.modificationRepository = modificationRepository; + this.objectMapper = objectMapper; } @Transactional(readOnly = true) - public PageResponse pageQuery(Long firearmId, Pageable pageable) { - Page page = firearmId == null - ? modificationRepository.findAllBy(pageable) - : modificationRepository.findAllByFirearm_Id(firearmId, pageable); + public PageResponse pageQuery(Long firearmId, List tags, Pageable pageable) { + String tagsJson = null; + if (tags != null && !tags.isEmpty()) { + try { + tagsJson = objectMapper.writeValueAsString(tags); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize tags", e); + } + } + + Page page; + if (tagsJson != null || firearmId != null) { + page = modificationRepository.pageQueryByFirearmAndTags(firearmId, tagsJson, pageable); + } else { + page = modificationRepository.findAllBy(pageable); + } return PageResponse.from(page.map(ModificationResponse::from)); } @@ -35,5 +53,9 @@ public class ModificationService { .map(ModificationResponse::from) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); } -} + @Transactional(readOnly = true) + public List findAllTags(Long firearmId) { + return modificationRepository.findAllTags(firearmId); + } +} From 09926353917bd28776fd305d09973584bfee2e5f Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 9 Apr 2026 11:37:02 +0800 Subject: [PATCH 011/117] feat: add nullability annotations to findById method in ModificationRepository --- .../repository/ModificationRepository.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index 5fd88d4..d4f891a 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -1,6 +1,7 @@ package com.onixbyte.deltaforceguide.repository; import com.onixbyte.deltaforceguide.domain.entity.Modification; +import org.jspecify.annotations.NonNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; @@ -23,15 +24,16 @@ public interface ModificationRepository extends JpaRepository findById(Long id); + @NonNull + Optional findById(@NonNull Long id); @Query(value = """ - SELECT * FROM modification m + SELECT * FROM modification m WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) """, countQuery = """ - SELECT count(*) FROM modification m + SELECT count(*) FROM modification m WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) """, From bd1f2441f3845577de14f674140b70bde156f744 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 9 Apr 2026 13:28:28 +0800 Subject: [PATCH 012/117] feat: add calibre, fire rate, armour damage, and body damage fields to Firearm model and update related response and migration scripts --- .../domain/dto/FirearmResponse.java | 8 +++ .../domain/entity/Firearm.java | 44 ++++++++++++++ .../db/migration/V3__bullet_and_damages.sql | 60 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 src/main/resources/db/migration/V3__bullet_and_damages.sql diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java index fc62d43..eb5b1ac 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java @@ -8,6 +8,10 @@ public record FirearmResponse( String name, FirearmType type, String level, + String calibre, + Integer fireRate, + Integer armourDamage, + Integer bodyDamage, String review ) { public static FirearmResponse from(Firearm firearm) { @@ -16,6 +20,10 @@ public record FirearmResponse( firearm.getName(), firearm.getType(), firearm.getLevel(), + firearm.getCalibre(), + firearm.getFireRate(), + firearm.getArmourDamage(), + firearm.getBodyDamage(), firearm.getReview() ); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java index 9db6dc4..c38173f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java @@ -36,6 +36,18 @@ public class Firearm { @Column(name = "review", columnDefinition = "TEXT") private String review; + @Column(name = "calibre") + private String calibre; + + @Column(name = "fire_rate") + private Integer fireRate; + + @Column(name = "armour_damage") + private Integer armourDamage; + + @Column(name = "body_damage") + private Integer bodyDamage; + @OneToMany(mappedBy = "firearm", cascade = CascadeType.ALL, orphanRemoval = true) private List modifications = new ArrayList<>(); @@ -79,6 +91,38 @@ public class Firearm { this.review = review; } + public String getCalibre() { + return calibre; + } + + public void setCalibre(String calibre) { + this.calibre = calibre; + } + + public Integer getFireRate() { + return fireRate; + } + + public void setFireRate(Integer fireRate) { + this.fireRate = fireRate; + } + + public Integer getArmourDamage() { + return armourDamage; + } + + public void setArmourDamage(Integer armourDamage) { + this.armourDamage = armourDamage; + } + + public Integer getBodyDamage() { + return bodyDamage; + } + + public void setBodyDamage(Integer bodyDamage) { + this.bodyDamage = bodyDamage; + } + public List getModifications() { return modifications; } diff --git a/src/main/resources/db/migration/V3__bullet_and_damages.sql b/src/main/resources/db/migration/V3__bullet_and_damages.sql new file mode 100644 index 0000000..97ac135 --- /dev/null +++ b/src/main/resources/db/migration/V3__bullet_and_damages.sql @@ -0,0 +1,60 @@ +-- 创建新表 +CREATE TABLE firearm_new +( + id BIGSERIAL NOT NULL, + name VARCHAR(64) NOT NULL, + type INTEGER NOT NULL, + level VARCHAR(10) NOT NULL, + calibre VARCHAR(20) NOT NULL, + fire_rate INTEGER NOT NULL, + armour_damage INTEGER NOT NULL, + body_damage INTEGER NOT NULL, + review TEXT NULL, + CONSTRAINT firearm_new_pkey PRIMARY KEY (id) +); + +-- 迁移数据 +INSERT INTO firearm_new(id, name, type, level, calibre, fire_rate, armour_damage, body_damage, + review) +SELECT id, + name, + type, + level, + calibre, + 0, + armour_damage, + body_damage, + review +FROM firearm; + +-- 处理外键(关键步骤) +-- 先删除指向旧表的外键约束 +ALTER TABLE modification + DROP CONSTRAINT fk_modification_firearm; + +-- 重命名旧表和索引 +ALTER TABLE firearm + RENAME TO firearm_legacy; +ALTER INDEX firearm_pkey RENAME TO firearm_legacy_pkey; + +-- 重命名新表和索引 +ALTER TABLE firearm_new + RENAME TO firearm; +ALTER INDEX firearm_new_pkey RENAME TO firearm_pkey; + +-- 重新建立外键,指向新的 firearm 表 +ALTER TABLE modification + ADD CONSTRAINT fk_modification_firearm + FOREIGN KEY (firearm_id) REFERENCES firearm (id); + +-- 序列所有权与名称修正 +ALTER SEQUENCE firearm_id_seq RENAME TO firearm_legacy_id_seq; +ALTER SEQUENCE firearm_new_id_seq RENAME TO firearm_id_seq; +ALTER SEQUENCE firearm_id_seq OWNED BY firearm.id; + +-- 更新序列计数器 +SELECT setval('firearm_id_seq', coalesce(max(id), 1)) +FROM firearm; + +-- 删除旧表 +DROP TABLE IF EXISTS firearm_legacy CASCADE; \ No newline at end of file From e65df08d1b1eb7d5daa4a6a6cb8bfebf1854ac19 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Sun, 12 Apr 2026 05:32:31 +0800 Subject: [PATCH 013/117] feat: implement User and UserCredential models with repository and service layers --- .../deltaforceguide/domain/entity/User.java | 73 +++++++++++++ .../domain/entity/UserCredential.java | 83 +++++++++++++++ .../domain/entity/UserCredentialId.java | 49 +++++++++ .../manager/UserCredentialManager.java | 47 ++++++++ .../deltaforceguide/manager/UserManager.java | 50 +++++++++ .../repository/UserCredentialRepository.java | 52 +++++++++ .../repository/UserRepository.java | 29 +++++ .../deltaforceguide/service/UserService.java | 100 ++++++++++++++++++ src/main/resources/db/migration/V4__user.sql | 16 +++ 9 files changed, 499 insertions(+) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/UserService.java create mode 100644 src/main/resources/db/migration/V4__user.sql diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java new file mode 100644 index 0000000..31cf527 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -0,0 +1,73 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "app_user") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "username", nullable = false) + private String username; + + @Column(name = "email", nullable = false) + private String email; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List credentials = new ArrayList<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public List getCredentials() { + return credentials; + } + + public void setCredentials(List credentials) { + this.credentials = credentials; + } + + public void addCredential(UserCredential credential) { + this.credentials.add(credential); + credential.setUser(this); + } + + public void removeCredential(UserCredential credential) { + this.credentials.remove(credential); + credential.setUser(null); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java new file mode 100644 index 0000000..44e409e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java @@ -0,0 +1,83 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "app_user_credential") +public class UserCredential { + + @EmbeddedId + @AttributeOverrides({ + @AttributeOverride(name = "userId", column = @Column(name = "user_id")), + @AttributeOverride(name = "provider", column = @Column(name = "provider")) + }) + private UserCredentialId id = new UserCredentialId(); + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user")) + private User user; + + @Column(name = "credential", nullable = false, length = 255) + private String credential; + + public UserCredentialId getId() { + return id; + } + + public void setId(UserCredentialId id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + if (this.id == null) { + this.id = new UserCredentialId(); + } + this.id.setUserId(user == null ? null : user.getId()); + } + + public Long getUserId() { + return id == null ? null : id.getUserId(); + } + + public void setUserId(Long userId) { + if (this.id == null) { + this.id = new UserCredentialId(); + } + this.id.setUserId(userId); + } + + public String getProvider() { + return id == null ? null : id.getProvider(); + } + + public void setProvider(String provider) { + if (this.id == null) { + this.id = new UserCredentialId(); + } + this.id.setProvider(provider); + } + + public String getCredential() { + return credential; + } + + public void setCredential(String credential) { + this.credential = credential; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java new file mode 100644 index 0000000..ac1a98c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java @@ -0,0 +1,49 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.Embeddable; + +import java.io.Serializable; +import java.util.Objects; + +@Embeddable +public class UserCredentialId implements Serializable { + + private Long userId; + + private String provider; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserCredentialId that = (UserCredentialId) o; + return Objects.equals(userId, that.userId) && Objects.equals(provider, that.provider); + } + + @Override + public int hashCode() { + return Objects.hash(userId, provider); + } +} + + diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java new file mode 100644 index 0000000..f4aa494 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java @@ -0,0 +1,47 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.repository.UserCredentialRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Component +public class UserCredentialManager { + + private final UserCredentialRepository userCredentialRepository; + + public UserCredentialManager(UserCredentialRepository userCredentialRepository) { + this.userCredentialRepository = userCredentialRepository; + } + + @Transactional(readOnly = true) + public List findAllByUserId(Long userId) { + return userCredentialRepository.findAllByUserId(userId); + } + + @Transactional(readOnly = true) + public Optional findByUserIdAndProvider(Long userId, String provider) { + return userCredentialRepository.findByUserIdAndProvider(userId, provider); + } + + @Transactional + public UserCredential save(UserCredential userCredential) { + return userCredentialRepository.save(userCredential); + } + + @Transactional + public void deleteByUserIdAndProvider(Long userId, String provider) { + userCredentialRepository.deleteByUserIdAndProvider(userId, provider); + } + + @Transactional + public void deleteAllByUserId(Long userId) { + userCredentialRepository.deleteAllByUserId(userId); + } +} + + + diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java new file mode 100644 index 0000000..08ffa3c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java @@ -0,0 +1,50 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.repository.UserRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Component +public class UserManager { + + private final UserRepository userRepository; + + public UserManager(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional(readOnly = true) + public Optional findById(Long id) { + return userRepository.findById(id); + } + + @Transactional(readOnly = true) + public List findAll() { + return userRepository.findAll(); + } + + @Transactional(readOnly = true) + public Optional findByUsername(String username) { + return userRepository.findByUsername(username); + } + + @Transactional(readOnly = true) + public Optional findByEmail(String email) { + return userRepository.findByEmail(email); + } + + @Transactional + public User save(User user) { + return userRepository.save(user); + } + + @Transactional + public void deleteById(Long id) { + userRepository.deleteById(id); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java new file mode 100644 index 0000000..94fb8ec --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java @@ -0,0 +1,52 @@ +package com.onixbyte.deltaforceguide.repository; + +import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.domain.entity.UserCredentialId; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserCredentialRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"user"}) + @Query(""" + select uc + from UserCredential uc + where uc.user.id = :userId + """) + List findAllByUserId(@Param("userId") Long userId); + + @EntityGraph(attributePaths = {"user"}) + @Query(""" + select uc + from UserCredential uc + where uc.user.id = :userId + and uc.id.provider = :provider + """) + Optional findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider); + + @Modifying + @Query(""" + delete from UserCredential uc + where uc.user.id = :userId + and uc.id.provider = :provider + """) + void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider); + + @Modifying + @Query(""" + delete from UserCredential uc + where uc.user.id = :userId + """) + void deleteAllByUserId(@Param("userId") Long userId); +} + + + diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java new file mode 100644 index 0000000..3efa050 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java @@ -0,0 +1,29 @@ +package com.onixbyte.deltaforceguide.repository; + +import com.onixbyte.deltaforceguide.domain.entity.User; +import org.jspecify.annotations.NonNull; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + @Override + @EntityGraph(attributePaths = {"credentials"}) + @NonNull + Optional findById(@NonNull Long id); + + @EntityGraph(attributePaths = {"credentials"}) + Optional findByUsername(String username); + + @EntityGraph(attributePaths = {"credentials"}) + Optional findByEmail(String email); + + boolean existsByUsername(String username); + + boolean existsByEmail(String email); +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java new file mode 100644 index 0000000..c57ce4f --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -0,0 +1,100 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.manager.UserCredentialManager; +import com.onixbyte.deltaforceguide.manager.UserManager; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +public class UserService { + + private final UserManager userManager; + private final UserCredentialManager userCredentialManager; + + public UserService(UserManager userManager, UserCredentialManager userCredentialManager) { + this.userManager = userManager; + this.userCredentialManager = userCredentialManager; + } + + @Transactional(readOnly = true) + public List findAll() { + return userManager.findAll(); + } + + @Transactional(readOnly = true) + public User queryById(Long id) { + return userManager.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id)); + } + + @Transactional(readOnly = true) + public User queryByUsername(String username) { + return userManager.findByUsername(username) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + username)); + } + + @Transactional + public User create(User user) { + return userManager.save(user); + } + + @Transactional + public User update(User user) { + if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId()); + } + return userManager.save(user); + } + + @Transactional(readOnly = true) + public List findCredentials(Long userId) { + ensureUserExists(userId); + return userCredentialManager.findAllByUserId(userId); + } + + @Transactional(readOnly = true) + public UserCredential queryCredential(Long userId, String provider) { + ensureUserExists(userId); + return userCredentialManager.findByUserIdAndProvider(userId, provider) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "User credential not found: userId=" + userId + ", provider=" + provider)); + } + + @Transactional + public UserCredential upsertCredential(Long userId, String provider, String credential) { + User user = ensureUserExists(userId); + UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider) + .orElseGet(UserCredential::new); + userCredential.setUser(user); + userCredential.setProvider(provider); + userCredential.setCredential(credential); + return userCredentialManager.save(userCredential); + } + + @Transactional + public void deleteCredential(Long userId, String provider) { + ensureUserExists(userId); + userCredentialManager.deleteByUserIdAndProvider(userId, provider); + } + + @Transactional + public void deleteById(Long id) { + ensureUserExists(id); + userCredentialManager.deleteAllByUserId(id); + userManager.deleteById(id); + } + + private User ensureUserExists(Long userId) { + return userManager.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + userId)); + } +} + + diff --git a/src/main/resources/db/migration/V4__user.sql b/src/main/resources/db/migration/V4__user.sql new file mode 100644 index 0000000..f3df299 --- /dev/null +++ b/src/main/resources/db/migration/V4__user.sql @@ -0,0 +1,16 @@ +DROP TABLE IF EXISTS app_user; +CREATE TABLE app_user +( + id BIGSERIAL NOT NULL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL +); + +DROP TABLE IF EXISTS app_user_credential; +CREATE TABLE app_user_credential +( + user_id BIGINT NOT NULL REFERENCES app_user (id), + provider VARCHAR(255) NOT NULL, + credential VARCHAR(255) NOT NULL, + CONSTRAINT app_user_credential_pkey PRIMARY KEY (user_id, provider) +); \ No newline at end of file From 0a6813ceeaf1abbc4898cdf09720cd935dac8463 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Sun, 12 Apr 2026 05:37:24 +0800 Subject: [PATCH 014/117] chore: add Spring Security to library --- build.gradle.kts | 4 +++- gradle/libs.versions.toml | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8a5dd62..0ce2b78 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,10 +49,12 @@ dependencies { implementation(libs.flyway.core) implementation(libs.flyway.postgresql) implementation(libs.jackson.jsr310) - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16") + implementation(libs.spring.boot.starter.doc) + implementation(libs.spring.boot.starter.security) testImplementation(libs.spring.boot.starter.test) testImplementation(libs.reactor.test) testImplementation(libs.mybatis.starter.test) + testImplementation(libs.spring.security.test) runtimeOnly(libs.postgres.driver) testRuntimeOnly(libs.h2.database) testRuntimeOnly(libs.junit.launcher) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0429e9..89621f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ mybatisVersion = "3.0.5" jacksonVersion = "2.19.2" hypersistenceVersion = "3.14.0" springDependencyManagementVersion = "1.1.7" +springDocVersion = "2.8.16" [libraries] # General Utilities @@ -57,16 +58,19 @@ spring-boot-configurationProcessor = { group = "org.springframework.boot", name spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator" } # Security & Auth -spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security", version.ref = "springBootVersion" } +spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" } jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" } +# Spring Doc +spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" } + # Cloud Services aws-sdk-bom = { group = "software.amazon.awssdk", name = "bom", version.ref = "awsSdkVersion" } aws-sdk-s3 = { group = "software.amazon.awssdk", name = "s3" } # Testing spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "springBootVersion" } -spring-security-test = { group = "org.springframework.security", name = "spring-security-test", version.ref = "springSecurityVersion" } +spring-security-test = { group = "org.springframework.security", name = "spring-security-test" } reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref = "reactorVersion" } junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" } mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" } From 5e9b29c186b2bb99a9c42eb22bd1c2244c256592 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 13 Apr 2026 14:32:34 +0800 Subject: [PATCH 015/117] feat: implement JWT authentication with TokenClient, TokenAuthenticationFilter, and SecurityConfig --- .../deltaforceguide/client/TokenClient.java | 70 ++++++++++++++ .../deltaforceguide/config/CorsConfig.java | 55 +++++------ .../config/SecurityConfig.java | 92 +++++++++++++++++++ .../exeption/BizException.java | 35 +++++++ .../filter/TokenAuthenticationFilter.java | 83 +++++++++++++++++ .../properties/TokenProperties.java | 13 +++ .../UsernamePasswordAuthentication.java | 76 +++++++++++++++ ...sernamePasswordAuthenticationProvider.java | 83 +++++++++++++++++ .../shared/CredentialProvider.java | 6 ++ .../deltaforceguide/utils/DateTimeUtil.java | 13 +++ src/main/resources/application.yaml | 1 - 11 files changed, 494 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java new file mode 100644 index 0000000..d4b745c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java @@ -0,0 +1,70 @@ +package com.onixbyte.deltaforceguide.client; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.properties.TokenProperties; +import com.onixbyte.deltaforceguide.utils.DateTimeUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +public class TokenClient { + + private final Algorithm algorithm; + private final TokenProperties tokenProperties; + private final JWTVerifier verifier; + + /** + * Constructs a new TokenClient with the necessary algorithm and token properties. + * + * @param algorithm the signing algorithm used to secure the JWT + * @param tokenProperties the configuration properties for the token, such as issuer and + * validity period + */ + @Autowired + public TokenClient( + Algorithm algorithm, + TokenProperties tokenProperties, + JWTVerifier verifier + ) { + this.algorithm = algorithm; + this.tokenProperties = tokenProperties; + this.verifier = verifier; + } + + /** + * Generate a JSON Web Token to the current user. + * + * @param user the current user for whom the token is being generated + * @return a JWT string + */ + public String generateToken(User user) { + var issuedAt = LocalDateTime.now(); + var expiresAt = issuedAt.plus(tokenProperties.validTime()); + + return JWT.create() + .withSubject(user.getUsername()) + .withIssuer(tokenProperties.issuer()) + .withIssuedAt(DateTimeUtil.asInstant(issuedAt)) + .withExpiresAt(DateTimeUtil.asInstant(expiresAt)) + .sign(algorithm); + } + + /** + * Verify and decode token. + * + * @param token a JWT token + * @return information included in the given token + * @throws com.auth0.jwt.exceptions.JWTVerificationException if the token is invalid, such as + * expired, or not signed by + * specific server + */ + public DecodedJWT verifyToken(String token) { + return verifier.verify(token); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java index 916ff57..291695b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java @@ -2,46 +2,37 @@ package com.onixbyte.deltaforceguide.config; import com.onixbyte.deltaforceguide.properties.CorsProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Optional; +import java.util.List; import java.util.stream.Stream; @Configuration @EnableConfigurationProperties({CorsProperties.class}) -public class CorsConfig implements WebMvcConfigurer { +public class CorsConfig { - private final CorsProperties properties; - - public CorsConfig(CorsProperties properties) { - this.properties = properties; - } - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOrigins(toSafeArray(properties.allowedOrigins())) - .allowedHeaders(toSafeArray(properties.allowedHeaders())) - .allowedMethods(toHttpMethodNames(properties.allowedMethods())) - .allowCredentials(properties.allowCredentials()) - .maxAge(properties.maxAge().toSeconds()) - .exposedHeaders(toSafeArray(properties.exposedHeaders())); - } - - private static String[] toSafeArray(String[] values) { - return values == null ? new String[0] : values; - } - - private static String[] toHttpMethodNames(HttpMethod[] methods) { - return Optional.ofNullable(methods) - .stream() - .flatMap(Stream::of) + @Bean + public CorsConfigurationSource corsConfigurationSource( + CorsProperties properties + ) { + var corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowCredentials(properties.allowCredentials()); + corsConfiguration.setAllowedOrigins(List.of(properties.allowedOrigins())); + corsConfiguration.setAllowedHeaders(List.of(properties.allowedHeaders())); + corsConfiguration.setAllowedMethods(Stream.of(properties.allowedMethods()) .map(HttpMethod::name) - .toList() - .toArray(String[]::new); - } + .toList()); + corsConfiguration.setMaxAge(properties.maxAge()); + corsConfiguration.setAllowPrivateNetwork(properties.allowPrivateNetwork()); + corsConfiguration.setExposedHeaders(List.of(properties.exposedHeaders())); + var corsConfigurationSource = new UrlBasedCorsConfigurationSource(); + corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration); + return corsConfigurationSource; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java new file mode 100644 index 0000000..effe916 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -0,0 +1,92 @@ +package com.onixbyte.deltaforceguide.config; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; +import com.onixbyte.deltaforceguide.properties.TokenProperties; +import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.web.cors.CorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@EnableConfigurationProperties({TokenProperties.class}) +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity httpSecurity, + CorsConfigurationSource corsConfigurationSource, + TokenAuthenticationFilter tokenAuthenticationFilter + ) throws Exception { + return httpSecurity + .cors((cors) -> cors + .configurationSource(corsConfigurationSource)) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((customiser) -> customiser + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests((customiser) -> customiser + .requestMatchers("/error", "/error/**").permitAll() + .requestMatchers("/captcha", "/captcha/**").permitAll() + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/auth/logout").authenticated() + .requestMatchers( + "/swagger-ui.html", + "/swagger-ui", + "/swagger-ui/**", + "/v3/api-docs", + "/v3/api-docs.yaml", + "/v3/api-docs/swagger-config" + ).permitAll() + .requestMatchers(HttpMethod.GET, + "/firearms", "/firearms/*", + "/modifications", "/modifications/*" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager( + UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider + ) { + return new ProviderManager( + usernamePasswordAuthenticationProvider + ); + } + + @Bean + public Algorithm algorithm(TokenProperties properties) { + return Algorithm.HMAC256(properties.secret()); + } + + @Bean + public JWTVerifier verifier(Algorithm algorithm, TokenProperties tokenProperties) { + return JWT.require(algorithm) + .withIssuer(tokenProperties.issuer()) + .build(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java new file mode 100644 index 0000000..aad96d2 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java @@ -0,0 +1,35 @@ +package com.onixbyte.deltaforceguide.exeption; + +import org.springframework.http.HttpStatus; + +public class BizException extends RuntimeException { + + /** + * The HTTP status code associated with this business exception. + *

+ * This status code indicates the appropriate HTTP response status that should be returned to + * clients when this exception occurs. It enables consistent error handling across + * REST API endpoints. + */ + private final HttpStatus status; + + public BizException(String message) { + super(message); + this.status = HttpStatus.INTERNAL_SERVER_ERROR; + } + + public BizException(HttpStatus status, String message) { + super(message); + this.status = status; + } + + /** + * Returns the HTTP status code associated with this business exception. + * + * @return the HTTP status code that should be used in the error response + */ + public HttpStatus getStatus() { + return status; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..8cfd219 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -0,0 +1,83 @@ +package com.onixbyte.deltaforceguide.filter; + +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.onixbyte.deltaforceguide.client.TokenClient; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.manager.UserManager; +import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.util.WebUtils; + +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +@Component +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class); + + private final UserManager userManager; + private final TokenClient tokenClient; + private final HandlerExceptionResolver handlerExceptionResolver; + + public TokenAuthenticationFilter( + UserManager userManager, + TokenClient tokenClient, + HandlerExceptionResolver handlerExceptionResolver + ) { + this.userManager = userManager; + this.tokenClient = tokenClient; + this.handlerExceptionResolver = handlerExceptionResolver; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + var token = Optional.ofNullable(WebUtils.getCookie(request, "AccessToken")) + .map(Cookie::getValue) + .orElse(null); + if (Objects.isNull(token) || token.isBlank()) { + filterChain.doFilter(request, response); + return; + } + + try { + var decodedToken = tokenClient.verifyToken(token); + var username = decodedToken.getSubject(); + + var userWrapper = userManager.findByUsername(username); + if (userWrapper.isEmpty()) { + throw new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录"); + } + + var user = userWrapper.get(); + var authentication = UsernamePasswordAuthentication.authenticated(user); + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } catch (JWTVerificationException e) { + log.error("JWT verification failed.", e); + handlerExceptionResolver.resolveException(request, response, null, + new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录")); + } catch (BizException e) { + handlerExceptionResolver.resolveException(request, response, null, e); + } + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java new file mode 100644 index 0000000..0d7a618 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "app.jwt") +public record TokenProperties( + String issuer, + String secret, + Duration validTime +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java new file mode 100644 index 0000000..692efaf --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java @@ -0,0 +1,76 @@ +package com.onixbyte.deltaforceguide.security.authentication; + +import com.onixbyte.deltaforceguide.domain.entity.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.List; + +public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer { + private final String username; + private String password; + private boolean authenticated; + private User user; + + private UsernamePasswordAuthentication(String username, String password, boolean authenticated, User user) { + this.username = username; + this.password = password; + this.authenticated = authenticated; + this.user = user; + } + + public static UsernamePasswordAuthentication unauthenticated(String username, String password) { + return new UsernamePasswordAuthentication(username, password, false, null); + } + + public static UsernamePasswordAuthentication authenticated(User user) { + return new UsernamePasswordAuthentication(user.getUsername(), null, true, user); + } + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getCredentials() { + return password; + } + + @Override + public User getDetails() { + return user; + } + + @Override + public String getPrincipal() { + return username; + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } + + @Override + public void setAuthenticated(boolean authenticated) throws IllegalArgumentException { + this.authenticated = authenticated; + } + + @Override + public String getName() { + return username; + } + + @Override + public void eraseCredentials() { + this.password = null; + } + + public void setDetails(User user) { + this.user = user; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java new file mode 100644 index 0000000..b32c814 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -0,0 +1,83 @@ +package com.onixbyte.deltaforceguide.security.provider; + +import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.manager.UserManager; +import com.onixbyte.deltaforceguide.repository.UserCredentialRepository; +import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import com.onixbyte.deltaforceguide.shared.CredentialProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Example; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider { + + private static final Logger log = LoggerFactory.getLogger(UsernamePasswordAuthenticationProvider.class); + private final UserManager userManager; + private final PasswordEncoder passwordEncoder; + private final UserCredentialRepository userCredentialRepository; + + @Autowired + public UsernamePasswordAuthenticationProvider( + UserManager userManager, + PasswordEncoder passwordEncoder, + UserCredentialRepository userCredentialRepository + ) { + this.userManager = userManager; + this.passwordEncoder = passwordEncoder; + this.userCredentialRepository = userCredentialRepository; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "用户认证失败,请稍后再试。"); + } + + // get userContainer from database + var userContainer = userManager.findByUsername(usernamePasswordAuthentication.getPrincipal()); + if (userContainer.isEmpty()) { + log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal()); + throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); + } + + var user = userContainer.get(); + + var userCredentialExample = new UserCredential(); + userCredentialExample.setUserId(user.getId()); + userCredentialExample.setProvider(CredentialProvider.LOCAL); + + // get userContainer credentials from database + var userCredentials = userCredentialRepository.findOne(Example.of(userCredentialExample)) + .orElseThrow(() -> new BizException(HttpStatus.UNAUTHORIZED, "您还没有配置密码,请联系管理员处理。")); + + // validate password + if (!passwordEncoder.matches(usernamePasswordAuthentication.getCredentials(), userCredentials.getCredential())) { + log.error("User {} is trying to authenticate but password is incorrect.", usernamePasswordAuthentication.getPrincipal()); + throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); + } + + // erase credentials + usernamePasswordAuthentication.eraseCredentials(); + + // set values + usernamePasswordAuthentication.setAuthenticated(true); + usernamePasswordAuthentication.setDetails(user); + + return usernamePasswordAuthentication; + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthentication.class.isAssignableFrom(authentication); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java new file mode 100644 index 0000000..115f935 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.shared; + +public class CredentialProvider { + + public static final String LOCAL = "LOCAL"; +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java new file mode 100644 index 0000000..f1360c8 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.utils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +public class DateTimeUtil { + + public static Instant asInstant(LocalDateTime ldt) { + return ldt.atZone(ZoneId.systemDefault()) + .toInstant(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 65b087c..66611ee 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -43,4 +43,3 @@ logging: level: org.hibernate: orm.connections.pooling: off - From 75abbb0a2a32551f3564a7995c6e54593b7222cf Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 13 Apr 2026 14:38:50 +0800 Subject: [PATCH 016/117] feat: add Swagger annotations for Firearm, Modification, and Tag controllers --- .../deltaforceguide/controller/FirearmController.java | 7 ++++++- .../deltaforceguide/controller/ModificationController.java | 7 ++++++- .../onixbyte/deltaforceguide/controller/TagController.java | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index f4d7a36..d6e8ca7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -4,6 +4,8 @@ import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.enumeration.FirearmType; import com.onixbyte.deltaforceguide.service.FirearmService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; @@ -15,7 +17,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Validated +@Tag(name = "武器管理", description = "与武器有关的操作") @RestController @RequestMapping("/firearms") public class FirearmController { @@ -26,6 +28,8 @@ public class FirearmController { this.firearmService = firearmService; } + @Operation(description = "获取分页武器数据") + @Validated @GetMapping public PageResponse pageQuery( @RequestParam(defaultValue = "0") @Min(0) int page, @@ -37,6 +41,7 @@ public class FirearmController { return firearmService.pageQuery(type, PageRequest.of(page, size, Sort.by(direction, sortBy))); } + @Operation(description = "获取指定武器的数据") @GetMapping("/{id}") public FirearmResponse queryById(@PathVariable Long id) { return firearmService.queryById(id); diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index 816adda..5f3b1c6 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -3,6 +3,8 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.service.ModificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; @@ -17,7 +19,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; -@Validated +@Tag(name = "改装管理", description = "对枪械改装的管理") @RestController @RequestMapping("/modifications") public class ModificationController { @@ -28,6 +30,8 @@ public class ModificationController { this.modificationService = modificationService; } + @Operation(description = "分页查询改装信息") + @Validated @GetMapping public PageResponse pageQuery( @RequestParam(defaultValue = "0") @Min(0) int page, @@ -40,6 +44,7 @@ public class ModificationController { return modificationService.pageQuery(firearmId, tags, PageRequest.of(page, size, Sort.by(direction, sortBy))); } + @Operation(description = "查询指定改装的信息") @GetMapping("/{id}") public ModificationResponse queryById(@PathVariable Long id) { return modificationService.queryById(id); diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java index 41230aa..e22097d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java @@ -1,5 +1,7 @@ package com.onixbyte.deltaforceguide.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -8,6 +10,7 @@ import com.onixbyte.deltaforceguide.service.ModificationService; import java.util.List; +@Tag(name = "标签管理", description = "管理标签信息") @RestController @RequestMapping("/tags") public class TagController { @@ -18,6 +21,7 @@ public class TagController { this.modificationService = modificationService; } + @Operation(description = "查询指定武器或所有武器的标签") @GetMapping public List getTags(@RequestParam(required = false) Long firearmId) { return modificationService.findAllTags(firearmId); From 8fbb73740ca5fdc0dcb359eef8b7a2168843501e Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 13 Apr 2026 17:25:34 +0800 Subject: [PATCH 017/117] feat: implement user authentication with login endpoint and cookie management --- .../config/SecurityConfig.java | 3 +- .../controller/AuthController.java | 40 ++++++++++++++++ .../controller/GlobalExceptionHandler.java | 19 ++++++++ .../domain/dto/ErrorResponse.java | 7 +++ .../domain/dto/LoginRequest.java | 9 ++++ .../domain/dto/UserResponse.java | 17 +++++++ .../filter/TokenAuthenticationFilter.java | 19 ++++++++ .../manager/CookieManager.java | 37 +++++++++++++++ .../deltaforceguide/manager/UserManager.java | 4 ++ .../properties/CookieProperties.java | 17 +++++++ .../repository/UserRepository.java | 11 +++++ ...sernamePasswordAuthenticationProvider.java | 2 +- .../deltaforceguide/service/AuthService.java | 40 ++++++++++++++++ .../service/CookieService.java | 47 +++++++++++++++++++ src/main/resources/db/migration/V4__user.sql | 8 ++-- 15 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index effe916..1ac6d71 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -4,6 +4,7 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; +import com.onixbyte.deltaforceguide.properties.CookieProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties; import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -26,7 +27,7 @@ import org.springframework.web.cors.CorsConfigurationSource; @Configuration @EnableWebSecurity @EnableMethodSecurity -@EnableConfigurationProperties({TokenProperties.class}) +@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class}) public class SecurityConfig { @Bean diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java new file mode 100644 index 0000000..172f8b7 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -0,0 +1,40 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; +import com.onixbyte.deltaforceguide.domain.dto.UserResponse; +import com.onixbyte.deltaforceguide.client.TokenClient; +import com.onixbyte.deltaforceguide.service.AuthService; +import com.onixbyte.deltaforceguide.service.CookieService; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + private final TokenClient tokenClient; + private final CookieService cookieService; + + public AuthController(AuthService authService, TokenClient tokenClient, CookieService cookieService) { + this.authService = authService; + this.tokenClient = tokenClient; + this.cookieService = cookieService; + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + var user = authService.login(request); + var accessToken = tokenClient.generateToken(user); + var accessTokenCookie = cookieService.buildCookie("AccessToken", accessToken); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + .body(UserResponse.from(user)); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..c0b19f1 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java @@ -0,0 +1,19 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.ErrorResponse; +import com.onixbyte.deltaforceguide.exeption.BizException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BizException.class) + public ResponseEntity handleBizException(BizException exception) { + var status = exception.getStatus(); + return ResponseEntity.status(status) + .body(new ErrorResponse(exception.getMessage())); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java new file mode 100644 index 0000000..f1efbfa --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record ErrorResponse( + String message +) { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java new file mode 100644 index 0000000..5dda62b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "登录名称不能为空") String principle, + @NotBlank(message = "登录口令不能为空") String credential +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java new file mode 100644 index 0000000..75d9188 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java @@ -0,0 +1,17 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.User; + +public record UserResponse( + Long id, + String username, + String email +) { + public static UserResponse from(User user) { + return new UserResponse( + user.getId(), + user.getUsername(), + user.getEmail() + ); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index 8cfd219..f4b9220 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import com.onixbyte.deltaforceguide.service.CookieService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; @@ -13,6 +14,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -22,6 +24,8 @@ import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.util.WebUtils; import java.io.IOException; +import java.time.Duration; +import java.time.Instant; import java.util.Objects; import java.util.Optional; @@ -29,18 +33,22 @@ import java.util.Optional; public class TokenAuthenticationFilter extends OncePerRequestFilter { private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class); + private static final Duration ACCESS_TOKEN_RENEW_THRESHOLD = Duration.ofMinutes(5); private final UserManager userManager; private final TokenClient tokenClient; + private final CookieService cookieService; private final HandlerExceptionResolver handlerExceptionResolver; public TokenAuthenticationFilter( UserManager userManager, TokenClient tokenClient, + CookieService cookieService, HandlerExceptionResolver handlerExceptionResolver ) { this.userManager = userManager; this.tokenClient = tokenClient; + this.cookieService = cookieService; this.handlerExceptionResolver = handlerExceptionResolver; } @@ -70,6 +78,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { var user = userWrapper.get(); var authentication = UsernamePasswordAuthentication.authenticated(user); SecurityContextHolder.getContext().setAuthentication(authentication); + + if (shouldRenew(decodedToken.getExpiresAt().toInstant())) { + var renewedToken = tokenClient.generateToken(user); + var renewedTokenCookie = cookieService.buildCookie("AccessToken", renewedToken); + response.addHeader(HttpHeaders.SET_COOKIE, renewedTokenCookie.toString()); + } + filterChain.doFilter(request, response); } catch (JWTVerificationException e) { log.error("JWT verification failed.", e); @@ -79,5 +94,9 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { handlerExceptionResolver.resolveException(request, response, null, e); } } + + private boolean shouldRenew(Instant expiresAt) { + return Duration.between(Instant.now(), expiresAt).compareTo(ACCESS_TOKEN_RENEW_THRESHOLD) < 0; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java new file mode 100644 index 0000000..7bec528 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java @@ -0,0 +1,37 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.properties.CookieProperties; +import org.springframework.boot.web.server.Cookie; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class CookieManager { + + private final CookieProperties cookieProperties; + + public CookieManager(CookieProperties cookieProperties) { + this.cookieProperties = cookieProperties; + } + + public Boolean getHttpOnly() { + return cookieProperties.httpOnly(); + } + + public Boolean getSecure() { + return cookieProperties.secure(); + } + + public Cookie.SameSite getSameSite() { + return cookieProperties.sameSite(); + } + + public String getPath() { + return cookieProperties.path(); + } + + public Duration getMaxAge() { + return cookieProperties.maxAge(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java index 08ffa3c..fb8ac56 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java @@ -46,5 +46,9 @@ public class UserManager { public void deleteById(Long id) { userRepository.deleteById(id); } + + public Optional findByUsernameOrEmail(String principal) { + return userRepository.findByUsernameOrEmail(principal); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java new file mode 100644 index 0000000..86dab77 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java @@ -0,0 +1,17 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.boot.web.server.Cookie; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "app.cookie") +public record CookieProperties( + @DefaultValue("true") Boolean httpOnly, + @DefaultValue("true") Boolean secure, + @DefaultValue("/") String path, + @DefaultValue("PT2H") Duration maxAge, + @DefaultValue("LAX") Cookie.SameSite sameSite +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java index 3efa050..7cecaad 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java @@ -4,6 +4,8 @@ import com.onixbyte.deltaforceguide.domain.entity.User; import org.jspecify.annotations.NonNull; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -25,5 +27,14 @@ public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); boolean existsByEmail(String email); + + @EntityGraph(attributePaths = {"credentials"}) + @Query(""" + select u + from User u + where u.username = :principal + or u.email = :principal + """) + Optional findByUsernameOrEmail(@Param("principal") String principal); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java index b32c814..d5e9a65 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -43,7 +43,7 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro } // get userContainer from database - var userContainer = userManager.findByUsername(usernamePasswordAuthentication.getPrincipal()); + var userContainer = userManager.findByUsernameOrEmail(usernamePasswordAuthentication.getPrincipal()); if (userContainer.isEmpty()) { log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal()); throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java new file mode 100644 index 0000000..b1bbfc7 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -0,0 +1,40 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.stereotype.Service; + +import java.util.Objects; +import java.util.Optional; + +@Service +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + private final AuthenticationManager authenticationManager; + + public AuthService(AuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + } + + public User login(LoginRequest request) { + var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication + .unauthenticated(request.principle(), request.credential())); + if (!(_authentication instanceof UsernamePasswordAuthentication authentication)) { + log.error( + "Type mismatched, required type is UsernamePasswordAuthentication but got {}.", + _authentication.getClass() + ); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "登录服务异常,请稍后再试。"); + } + + return authentication.getDetails(); + } + +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java new file mode 100644 index 0000000..764b47b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java @@ -0,0 +1,47 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.manager.CookieManager; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class CookieService { + + private final CookieManager cookieManager; + + public CookieService(CookieManager cookieManager) { + this.cookieManager = cookieManager; + } + + public ResponseCookie buildCookie(String cookieName, String value) { + return buildCookieInternal(cookieName, value, cookieManager.getMaxAge()); + } + + public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) { + return buildCookieInternal(cookieName, value, validDuration); + } + + /** + * Creates a response cookie builder with specified name, value and valid duration. + * + * @param name name of the cookie + * @param value value of the cookie + * @param maxAge valid duration of the cookie + * @return cookie builder + */ + protected ResponseCookie buildCookieInternal( + String name, + String value, + Duration maxAge + ) { + return ResponseCookie.from(name, value) + .secure(cookieManager.getSecure()) + .maxAge(maxAge) + .httpOnly(cookieManager.getHttpOnly()) + .path(cookieManager.getPath()) + .sameSite(cookieManager.getSameSite().attributeValue()) + .build(); + } +} diff --git a/src/main/resources/db/migration/V4__user.sql b/src/main/resources/db/migration/V4__user.sql index f3df299..0215d26 100644 --- a/src/main/resources/db/migration/V4__user.sql +++ b/src/main/resources/db/migration/V4__user.sql @@ -1,12 +1,14 @@ -DROP TABLE IF EXISTS app_user; +DROP TABLE IF EXISTS app_user CASCADE; CREATE TABLE app_user ( id BIGSERIAL NOT NULL PRIMARY KEY, username VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL + email VARCHAR(255) NOT NULL, + CONSTRAINT app_user_username_key UNIQUE (username), + CONSTRAINT app_user_email_key UNIQUE (email) ); -DROP TABLE IF EXISTS app_user_credential; +DROP TABLE IF EXISTS app_user_credential CASCADE; CREATE TABLE app_user_credential ( user_id BIGINT NOT NULL REFERENCES app_user (id), From 1fc7b932bc623e4d441eacb9880a9797aa5d7fa4 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 14 Apr 2026 12:13:02 +0800 Subject: [PATCH 018/117] feat: add logout endpoint and refactor cookie management in AuthController --- .../deltaforceguide/config/SecurityConfig.java | 2 +- .../deltaforceguide/controller/AuthController.java | 13 ++++++++++++- .../onixbyte/deltaforceguide/shared/CookieName.java | 6 ++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index 1ac6d71..f77a522 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -45,8 +45,8 @@ public class SecurityConfig { .authorizeHttpRequests((customiser) -> customiser .requestMatchers("/error", "/error/**").permitAll() .requestMatchers("/captcha", "/captcha/**").permitAll() - .requestMatchers("/auth/**").permitAll() .requestMatchers("/auth/logout").authenticated() + .requestMatchers("/auth/**").permitAll() .requestMatchers( "/swagger-ui.html", "/swagger-ui", diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index 172f8b7..481a549 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.domain.dto.UserResponse; import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.service.AuthService; import com.onixbyte.deltaforceguide.service.CookieService; +import com.onixbyte.deltaforceguide.shared.CookieName; import jakarta.validation.Valid; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -13,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.time.Duration; + @RestController @RequestMapping("/auth") public class AuthController { @@ -31,10 +34,18 @@ public class AuthController { public ResponseEntity login(@Valid @RequestBody LoginRequest request) { var user = authService.login(request); var accessToken = tokenClient.generateToken(user); - var accessTokenCookie = cookieService.buildCookie("AccessToken", accessToken); + var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) .body(UserResponse.from(user)); } + + @PostMapping("/logout") + public ResponseEntity logout() { + var expiredCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) + .build(); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java b/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java new file mode 100644 index 0000000..329852b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.shared; + +public class CookieName { + + public static final String ACCESS_TOKEN = "AccessToken"; +} From cb50892ffee8725c5195798c97602d76bdfdfe62 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Wed, 15 Apr 2026 11:14:19 +0800 Subject: [PATCH 019/117] feat: add builder pattern for Firearm, Modification, User, UserCredential, and UserCredentialId classes --- .../domain/entity/Firearm.java | 83 +++++++++++++++++++ .../domain/entity/Modification.java | 69 +++++++++++++++ .../deltaforceguide/domain/entity/User.java | 41 +++++++++ .../domain/entity/UserCredential.java | 55 ++++++++++++ .../domain/entity/UserCredentialId.java | 27 ++++++ 5 files changed, 275 insertions(+) diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java index c38173f..65942d3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java @@ -140,5 +140,88 @@ public class Firearm { this.modifications.remove(modification); modification.setFirearm(null); } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Long id; + private String name; + private FirearmType type; + private String level; + private String review; + private String calibre; + private Integer fireRate; + private Integer armourDamage; + private Integer bodyDamage; + private List modifications; + + public Builder id(Long id) { + this.id = id; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder type(FirearmType type) { + this.type = type; + return this; + } + + public Builder level(String level) { + this.level = level; + return this; + } + + public Builder review(String review) { + this.review = review; + return this; + } + + public Builder calibre(String calibre) { + this.calibre = calibre; + return this; + } + + public Builder fireRate(Integer fireRate) { + this.fireRate = fireRate; + return this; + } + + public Builder armourDamage(Integer armourDamage) { + this.armourDamage = armourDamage; + return this; + } + + public Builder bodyDamage(Integer bodyDamage) { + this.bodyDamage = bodyDamage; + return this; + } + + public Builder modifications(List modifications) { + this.modifications = modifications; + return this; + } + + public Firearm build() { + Firearm firearm = new Firearm(); + firearm.id = this.id; + firearm.name = this.name; + firearm.type = this.type; + firearm.level = this.level; + firearm.review = this.review; + firearm.calibre = this.calibre; + firearm.fireRate = this.fireRate; + firearm.armourDamage = this.armourDamage; + firearm.bodyDamage = this.bodyDamage; + firearm.modifications = this.modifications == null ? new ArrayList<>() : this.modifications; + return firearm; + } + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java index 6f29bd5..6ed3ec6 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java @@ -116,5 +116,74 @@ public class Modification { public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Long id; + private Firearm firearm; + private String name; + private String code; + private List tags; + private String note; + private String author; + private String videoUrl; + + public Builder id(Long id) { + this.id = id; + return this; + } + + public Builder firearm(Firearm firearm) { + this.firearm = firearm; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder code(String code) { + this.code = code; + return this; + } + + public Builder tags(List tags) { + this.tags = tags; + return this; + } + + public Builder note(String note) { + this.note = note; + return this; + } + + public Builder author(String author) { + this.author = author; + return this; + } + + public Builder videoUrl(String videoUrl) { + this.videoUrl = videoUrl; + return this; + } + + public Modification build() { + Modification modification = new Modification(); + modification.id = this.id; + modification.firearm = this.firearm; + modification.name = this.name; + modification.code = this.code; + modification.tags = this.tags == null ? new ArrayList<>() : this.tags; + modification.note = this.note; + modification.author = this.author; + modification.videoUrl = this.videoUrl; + return modification; + } + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java index 31cf527..67142df 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -70,4 +70,45 @@ public class User { this.credentials.remove(credential); credential.setUser(null); } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Long id; + private String username; + private String email; + private List credentials; + + public Builder id(Long id) { + this.id = id; + return this; + } + + public Builder username(String username) { + this.username = username; + return this; + } + + public Builder email(String email) { + this.email = email; + return this; + } + + public Builder credentials(List credentials) { + this.credentials = credentials; + return this; + } + + public User build() { + User user = new User(); + user.id = this.id; + user.username = this.username; + user.email = this.email; + user.credentials = this.credentials == null ? new ArrayList<>() : this.credentials; + return user; + } + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java index 44e409e..b8a09a6 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java @@ -80,4 +80,59 @@ public class UserCredential { public void setCredential(String credential) { this.credential = credential; } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private UserCredentialId id; + private User user; + private Long userId; + private String provider; + private String credential; + + public Builder id(UserCredentialId id) { + this.id = id; + return this; + } + + public Builder user(User user) { + this.user = user; + return this; + } + + public Builder userId(Long userId) { + this.userId = userId; + return this; + } + + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + public Builder credential(String credential) { + this.credential = credential; + return this; + } + + public UserCredential build() { + UserCredential userCredential = new UserCredential(); + userCredential.id = this.id == null ? new UserCredentialId() : this.id; + userCredential.user = this.user; + if (this.user != null) { + userCredential.id.setUserId(this.user.getId()); + } + if (this.userId != null) { + userCredential.id.setUserId(this.userId); + } + if (this.provider != null) { + userCredential.id.setProvider(this.provider); + } + userCredential.credential = this.credential; + return userCredential; + } + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java index ac1a98c..57a819e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java @@ -44,6 +44,33 @@ public class UserCredentialId implements Serializable { public int hashCode() { return Objects.hash(userId, provider); } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Long userId; + private String provider; + + public Builder userId(Long userId) { + this.userId = userId; + return this; + } + + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + public UserCredentialId build() { + UserCredentialId id = new UserCredentialId(); + id.userId = this.userId; + id.provider = this.provider; + return id; + } + } } From a58fefbd2d5375aa4bca1d8da0a8aa46e31d1a30 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 16 Apr 2026 09:52:55 +0800 Subject: [PATCH 020/117] feat: add addFirearm endpoint and FirearmRequest DTO for firearm creation --- .../controller/FirearmController.java | 12 +++++++----- .../domain/dto/FirearmRequest.java | 15 +++++++++++++++ .../deltaforceguide/service/FirearmService.java | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index d6e8ca7..2f723fe 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -1,5 +1,6 @@ package com.onixbyte.deltaforceguide.controller; +import com.onixbyte.deltaforceguide.domain.dto.FirearmRequest; import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.enumeration.FirearmType; @@ -11,11 +12,7 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "武器管理", description = "与武器有关的操作") @RestController @@ -46,5 +43,10 @@ public class FirearmController { public FirearmResponse queryById(@PathVariable Long id) { return firearmService.queryById(id); } + + @PostMapping + public FirearmResponse addFirearm(@Validated @RequestBody FirearmRequest request) { + return firearmService.addFirearm(request); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java new file mode 100644 index 0000000..74e1993 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java @@ -0,0 +1,15 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.enumeration.FirearmType; + +public record FirearmRequest( + String name, + FirearmType type, + String level, + String calibre, + Integer fireRate, + Integer armourDamage, + Integer bodyDamage, + String review +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java index 327254c..dc7f090 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java @@ -1,5 +1,6 @@ package com.onixbyte.deltaforceguide.service; +import com.onixbyte.deltaforceguide.domain.dto.FirearmRequest; import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.domain.entity.Firearm; @@ -36,5 +37,20 @@ public class FirearmService { .map(FirearmResponse::from) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); } + + public FirearmResponse addFirearm(FirearmRequest request) { + var firearm = firearmRepository.save(Firearm.builder() + .name(request.name()) + .type(request.type()) + .level(request.level()) + .calibre(request.calibre()) + .fireRate(request.fireRate()) + .armourDamage(request.armourDamage()) + .bodyDamage(request.bodyDamage()) + .review(request.review()) + .build()); + + return FirearmResponse.from(firearm); + } } From f0a8006097945dfcd9c30725a4fd7e14eda5c68d Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 17 Apr 2026 10:55:39 +0800 Subject: [PATCH 021/117] feat: add Swagger annotations for user authentication endpoints and update validation in LoginRequest --- .../deltaforceguide/controller/AuthController.java | 8 ++++++-- .../deltaforceguide/domain/dto/LoginRequest.java | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index 481a549..af60283 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -6,9 +6,11 @@ import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.service.AuthService; import com.onixbyte.deltaforceguide.service.CookieService; import com.onixbyte.deltaforceguide.shared.CookieName; -import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController; import java.time.Duration; +@Tag(name = "用户鉴权", description = "处理用户登录与退出功能") @RestController @RequestMapping("/auth") public class AuthController { @@ -30,8 +33,9 @@ public class AuthController { this.cookieService = cookieService; } + @Operation(description = "用户登录") @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + public ResponseEntity login(@Validated @RequestBody LoginRequest request) { var user = authService.login(request); var accessToken = tokenClient.generateToken(user); var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken); diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java index 5dda62b..272d119 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java @@ -1,9 +1,15 @@ package com.onixbyte.deltaforceguide.domain.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "登录请求") public record LoginRequest( - @NotBlank(message = "登录名称不能为空") String principle, - @NotBlank(message = "登录口令不能为空") String credential + @NotBlank(message = "登录名称不能为空") + @Schema(description = "用户名或电子邮箱") + String principle, + @NotBlank(message = "登录口令不能为空") + @Schema(description = "密码") + String credential ) { } From 17048104d9acf876b1f77851c9c41c8021646614 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 17 Apr 2026 10:57:41 +0800 Subject: [PATCH 022/117] feat: add logout operation description and update schema annotations in LoginRequest --- .../onixbyte/deltaforceguide/controller/AuthController.java | 1 + .../com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index af60283..6d7e4e5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -45,6 +45,7 @@ public class AuthController { .body(UserResponse.from(user)); } + @Operation(description = "退出登录") @PostMapping("/logout") public ResponseEntity logout() { var expiredCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO); diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java index 272d119..ab820ec 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java @@ -6,10 +6,10 @@ import jakarta.validation.constraints.NotBlank; @Schema(description = "登录请求") public record LoginRequest( @NotBlank(message = "登录名称不能为空") - @Schema(description = "用户名或电子邮箱") + @Schema(description = "用户名或电子邮箱", requiredMode = Schema.RequiredMode.REQUIRED) String principle, @NotBlank(message = "登录口令不能为空") - @Schema(description = "密码") + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) String credential ) { } From dec7f3c7d23a398b13a6b4386a6af4f3ca7bc32a Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 21 Apr 2026 14:07:17 +0800 Subject: [PATCH 023/117] feat: add Accessory and Tuning classes, update Modification to include accessories --- .../domain/entity/Accessory.java | 48 +++++++++++++++++++ .../domain/entity/Modification.java | 41 +++++++++++----- .../deltaforceguide/domain/entity/Tuning.java | 26 ++++++++++ .../V5__modification_accessories.sql | 2 + 4 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java create mode 100644 src/main/resources/db/migration/V5__modification_accessories.sql diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java new file mode 100644 index 0000000..8c4b6b9 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java @@ -0,0 +1,48 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import java.util.ArrayList; +import java.util.List; + +public class Accessory { + + private String slotName; + + private String accessoryName; + + private List tunings = new ArrayList<>(); + + public Accessory() { + } + + public String getSlotName() { + return slotName; + } + + public void setSlotName(String slotName) { + this.slotName = slotName; + } + + public String getAccessoryName() { + return accessoryName; + } + + public void setAccessoryName(String accessoryName) { + this.accessoryName = accessoryName; + } + + public List getTunings() { + return tunings; + } + + public void setTunings(List tunings) { + this.tunings = tunings; + } + + public void addTuning(Tuning tuning) { + this.tunings.add(tuning); + } + + public void removeTuning(Tuning tuning) { + this.tunings.remove(tuning); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java index 6ed3ec6..ed145b5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java @@ -1,17 +1,7 @@ package com.onixbyte.deltaforceguide.domain.entity; import io.hypersistence.utils.hibernate.type.json.JsonType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.ForeignKey; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import org.hibernate.annotations.Type; import java.util.ArrayList; @@ -41,9 +31,13 @@ public class Modification { private String code; @Type(JsonType.class) - @Column(name = "tags", columnDefinition = "json") + @Column(name = "tags", columnDefinition = "jsonb") private List tags = new ArrayList<>(); + @Type(JsonType.class) + @Column(name = "accessories", columnDefinition = "jsonb") + private List accessories = new ArrayList<>(); + @Column(name = "note", columnDefinition = "TEXT") private String note; @@ -93,6 +87,22 @@ public class Modification { this.tags = tags; } + public List getAccessories() { + return accessories; + } + + public void setAccessories(List accessories) { + this.accessories = accessories; + } + + public void addAccessory(Accessory modificationAccessory) { + this.accessories.add(modificationAccessory); + } + + public void removeAccessory(Accessory modificationAccessory) { + this.accessories.remove(modificationAccessory); + } + public String getNote() { return note; } @@ -128,6 +138,7 @@ public class Modification { private String name; private String code; private List tags; + private List accessories; private String note; private String author; private String videoUrl; @@ -157,6 +168,11 @@ public class Modification { return this; } + public Builder accessories(List accessories) { + this.accessories = accessories; + return this; + } + public Builder note(String note) { this.note = note; return this; @@ -179,6 +195,7 @@ public class Modification { modification.name = this.name; modification.code = this.code; modification.tags = this.tags == null ? new ArrayList<>() : this.tags; + modification.accessories = this.accessories == null ? new ArrayList<>() : this.accessories; modification.note = this.note; modification.author = this.author; modification.videoUrl = this.videoUrl; diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java new file mode 100644 index 0000000..4ab9879 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java @@ -0,0 +1,26 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +public class Tuning { + + private String tuningName; + private Double tuningValue; + + public Tuning() { + } + + public String getTuningName() { + return tuningName; + } + + public void setTuningName(String tuningName) { + this.tuningName = tuningName; + } + + public Double getTuningValue() { + return tuningValue; + } + + public void setTuningValue(Double tuningValue) { + this.tuningValue = tuningValue; + } +} diff --git a/src/main/resources/db/migration/V5__modification_accessories.sql b/src/main/resources/db/migration/V5__modification_accessories.sql new file mode 100644 index 0000000..1450826 --- /dev/null +++ b/src/main/resources/db/migration/V5__modification_accessories.sql @@ -0,0 +1,2 @@ +ALTER TABLE modification + ADD accessories JSONB NOT NULL DEFAULT '[]'; \ No newline at end of file From 93dbd857e09289775885fb58570ee57585af1b78 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 21 Apr 2026 14:20:45 +0800 Subject: [PATCH 024/117] feat: add update and delete operations for Firearm, including error handling --- .../controller/FirearmController.java | 13 +++++++++- .../service/FirearmService.java | 26 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index 2f723fe..817291e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -48,5 +48,16 @@ public class FirearmController { public FirearmResponse addFirearm(@Validated @RequestBody FirearmRequest request) { return firearmService.addFirearm(request); } -} + @Operation(description = "更新指定武器的数据") + @PutMapping("/{id}") + public FirearmResponse updateFirearm(@PathVariable Long id, @Validated @RequestBody FirearmRequest request) { + return firearmService.updateFirearm(id, request); + } + + @Operation(description = "删除指定武器的数据") + @DeleteMapping("/{id}") + public void deleteFirearm(@PathVariable Long id) { + firearmService.deleteFirearm(id); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java index dc7f090..3b69c15 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java @@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.enumeration.FirearmType; +import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -52,5 +53,28 @@ public class FirearmService { return FirearmResponse.from(firearm); } -} + @Transactional + public FirearmResponse updateFirearm(Long id, FirearmRequest request) { + var firearm = firearmRepository.findById(id) + .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); + + firearm.setName(request.name()); + firearm.setType(request.type()); + firearm.setLevel(request.level()); + firearm.setCalibre(request.calibre()); + firearm.setFireRate(request.fireRate()); + firearm.setArmourDamage(request.armourDamage()); + firearm.setBodyDamage(request.bodyDamage()); + firearm.setReview(request.review()); + + return FirearmResponse.from(firearmRepository.save(firearm)); + } + + @Transactional + public void deleteFirearm(Long id) { + Firearm firearm = firearmRepository.findById(id) + .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); + firearmRepository.delete(firearm); + } +} From 5ce8a994a40cbbca0cafe9037d02ea8b0c9cb89d Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 21 Apr 2026 23:39:05 +0800 Subject: [PATCH 025/117] feat: add modification creation and deletion endpoints, including batch operations and request DTOs --- .../controller/ModificationController.java | 38 +++++ .../domain/dto/AccessoryRequest.java | 20 +++ .../dto/ModificationBatchCreateRequest.java | 13 ++ .../dto/ModificationBatchDeleteRequest.java | 13 ++ .../domain/dto/ModificationRequest.java | 33 ++++ .../domain/dto/TuningRequest.java | 13 ++ .../service/ModificationService.java | 150 +++++++++++++++++- 7 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index 5f3b1c6..5b77c01 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -1,18 +1,26 @@ package com.onixbyte.deltaforceguide.controller; +import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchDeleteRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchCreateRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.service.ModificationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; 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; @@ -49,4 +57,34 @@ public class ModificationController { public ModificationResponse queryById(@PathVariable Long id) { return modificationService.queryById(id); } + + @Operation(description = "创建改装") + @PostMapping + public ModificationResponse create(@Valid @RequestBody ModificationRequest request) { + return modificationService.create(request); + } + + @Operation(description = "批量创建改装") + @PostMapping("/batch") + public List batchCreate(@Valid @RequestBody ModificationBatchCreateRequest request) { + return modificationService.batchCreate(request.modifications()); + } + + @Operation(description = "修改指定改装") + @PutMapping("/{id}") + public ModificationResponse update(@PathVariable Long id, @Valid @RequestBody ModificationRequest request) { + return modificationService.update(id, request); + } + + @Operation(description = "删除指定改装") + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) { + modificationService.delete(id); + } + + @Operation(description = "批量删除改装") + @DeleteMapping("/batch-delete") + public void batchDelete(@Valid @RequestBody ModificationBatchDeleteRequest request) { + modificationService.batchDelete(request.ids()); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java new file mode 100644 index 0000000..29fa638 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java @@ -0,0 +1,20 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +import java.util.ArrayList; +import java.util.List; + +public record AccessoryRequest( + @NotBlank(message = "插槽名称不能为空") + String slotName, + @NotBlank(message = "配件名称不能为空") + String accessoryName, + List<@Valid TuningRequest> tunings +) { + public List tunings() { + return tunings == null ? new ArrayList<>() : tunings; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java new file mode 100644 index 0000000..cdb3d93 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record ModificationBatchCreateRequest( + @NotEmpty(message = "批量创建列表不能为空") + List<@Valid ModificationRequest> modifications +) { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java new file mode 100644 index 0000000..7e46400 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +public record ModificationBatchDeleteRequest( + @NotEmpty(message = "批量删除ID列表不能为空") + List<@Positive(message = "ID必须为正数") Long> ids +) { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java new file mode 100644 index 0000000..c602dbd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java @@ -0,0 +1,33 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.util.ArrayList; +import java.util.List; + +public record ModificationRequest( + @NotNull(message = "武器ID不能为空") + @Positive(message = "武器ID必须为正数") + Long firearmId, + @NotBlank(message = "改装名称不能为空") + String name, + @NotBlank(message = "改装代码不能为空") + String code, + List<@NotBlank(message = "标签不能为空") String> tags, + List<@Valid AccessoryRequest> accessories, + String note, + String author, + String videoUrl +) { + public List tags() { + return tags == null ? new ArrayList<>() : tags; + } + + public List accessories() { + return accessories == null ? new ArrayList<>() : accessories; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java new file mode 100644 index 0000000..414becd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record TuningRequest( + @NotBlank(message = "调校项名称不能为空") + String tuningName, + @NotNull(message = "调校值不能为空") + Double tuningValue +) { +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index 4da45d3..39e5ec0 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -1,8 +1,15 @@ package com.onixbyte.deltaforceguide.service; +import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; +import com.onixbyte.deltaforceguide.domain.entity.Accessory; +import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Modification; +import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -13,16 +20,27 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; @Service public class ModificationService { private final ModificationRepository modificationRepository; + private final FirearmRepository firearmRepository; private final ObjectMapper objectMapper; - public ModificationService(ModificationRepository modificationRepository, ObjectMapper objectMapper) { + public ModificationService( + ModificationRepository modificationRepository, + FirearmRepository firearmRepository, + ObjectMapper objectMapper + ) { this.modificationRepository = modificationRepository; + this.firearmRepository = firearmRepository; this.objectMapper = objectMapper; } @@ -58,4 +76,134 @@ public class ModificationService { public List findAllTags(Long firearmId) { return modificationRepository.findAllTags(firearmId); } + + @Transactional + public ModificationResponse create(ModificationRequest request) { + Firearm firearm = firearmRepository.findById(request.firearmId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId())); + + Modification modification = toEntity(request, firearm); + return ModificationResponse.from(modificationRepository.save(modification)); + } + + @Transactional + public List batchCreate(List requests) { + Set firearmIds = requests.stream() + .map(ModificationRequest::firearmId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + + Map firearmMap = new HashMap<>(); + firearmRepository.findAllById(firearmIds).forEach(firearm -> firearmMap.put(firearm.getId(), firearm)); + + if (firearmMap.size() != firearmIds.size()) { + List missingFirearmIds = firearmIds.stream() + .filter(id -> !firearmMap.containsKey(id)) + .toList(); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + missingFirearmIds); + } + + List modifications = requests.stream() + .map(request -> toEntity(request, firearmMap.get(request.firearmId()))) + .toList(); + return modificationRepository.saveAll(modifications) + .stream() + .map(ModificationResponse::from) + .toList(); + } + + @Transactional + public ModificationResponse update(Long id, ModificationRequest request) { + Modification modification = modificationRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); + Firearm firearm = firearmRepository.findById(request.firearmId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId())); + + modification.setFirearm(firearm); + modification.setName(request.name()); + modification.setCode(request.code()); + modification.setTags(safeTags(request.tags())); + modification.setAccessories(toAccessories(request.accessories())); + modification.setNote(request.note()); + modification.setAuthor(request.author()); + modification.setVideoUrl(request.videoUrl()); + + return ModificationResponse.from(modificationRepository.save(modification)); + } + + @Transactional + public void delete(Long id) { + Modification modification = modificationRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); + modificationRepository.delete(modification); + } + + @Transactional + public void batchDelete(List ids) { + Set uniqueIds = new LinkedHashSet<>(ids); + List modifications = modificationRepository.findAllById(uniqueIds); + + if (modifications.size() != uniqueIds.size()) { + Set foundIds = modifications.stream() + .map(Modification::getId) + .collect(java.util.stream.Collectors.toSet()); + List missingIds = uniqueIds.stream() + .filter(id -> !foundIds.contains(id)) + .toList(); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + missingIds); + } + + modificationRepository.deleteAllInBatch(modifications); + } + + private Modification toEntity(ModificationRequest request, Firearm firearm) { + return Modification.builder() + .firearm(firearm) + .name(request.name()) + .code(request.code()) + .tags(safeTags(request.tags())) + .accessories(toAccessories(request.accessories())) + .note(request.note()) + .author(request.author()) + .videoUrl(request.videoUrl()) + .build(); + } + + private List safeTags(List tags) { + return tags == null ? new ArrayList<>() : tags; + } + + private List toAccessories(List accessoryRequests) { + if (accessoryRequests == null) { + return new ArrayList<>(); + } + + return accessoryRequests.stream() + .map(this::toAccessory) + .toList(); + } + + private Accessory toAccessory(AccessoryRequest request) { + Accessory accessory = new Accessory(); + accessory.setSlotName(request.slotName()); + accessory.setAccessoryName(request.accessoryName()); + accessory.setTunings(toTunings(request.tunings())); + return accessory; + } + + private List toTunings(List tuningRequests) { + if (tuningRequests == null) { + return new ArrayList<>(); + } + + return tuningRequests.stream() + .map(this::toTuning) + .toList(); + } + + private Tuning toTuning(TuningRequest request) { + Tuning tuning = new Tuning(); + tuning.setTuningName(request.tuningName()); + tuning.setTuningValue(request.tuningValue()); + return tuning; + } } From 353c05339e4bc47b14068d8bf0ba8fe88489c333 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Wed, 22 Apr 2026 16:35:39 +0800 Subject: [PATCH 026/117] feat: refactor batch delete endpoint to use request parameters and update SQL schema for firearm table --- .../deltaforceguide/controller/ModificationController.java | 6 +++--- src/main/resources/db/migration/V3__bullet_and_damages.sql | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index 5b77c01..1d3f90c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -1,6 +1,5 @@ package com.onixbyte.deltaforceguide.controller; -import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchDeleteRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchCreateRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; @@ -84,7 +83,8 @@ public class ModificationController { @Operation(description = "批量删除改装") @DeleteMapping("/batch-delete") - public void batchDelete(@Valid @RequestBody ModificationBatchDeleteRequest request) { - modificationService.batchDelete(request.ids()); + @Validated + public void batchDelete(@RequestParam List<@Positive Long> ids) { + modificationService.batchDelete(ids); } } diff --git a/src/main/resources/db/migration/V3__bullet_and_damages.sql b/src/main/resources/db/migration/V3__bullet_and_damages.sql index 97ac135..ae0fabf 100644 --- a/src/main/resources/db/migration/V3__bullet_and_damages.sql +++ b/src/main/resources/db/migration/V3__bullet_and_damages.sql @@ -1,4 +1,5 @@ -- 创建新表 +DROP TABLE IF EXISTS firearm_new; CREATE TABLE firearm_new ( id BIGSERIAL NOT NULL, @@ -20,10 +21,10 @@ SELECT id, name, type, level, - calibre, + '', + 0, + 0, 0, - armour_damage, - body_damage, review FROM firearm; From 384e17e79c771df5ab5dec2b2afa426cffe55109 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 23 Apr 2026 16:10:05 +0800 Subject: [PATCH 027/117] feat: add AccessoryResponse and TuningResponse DTOs for accessory and tuning data representation --- .../domain/dto/AccessoryResponse.java | 22 +++++++++++++++++++ .../domain/dto/ModificationResponse.java | 4 ++++ .../domain/dto/TuningResponse.java | 16 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java new file mode 100644 index 0000000..421410d --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java @@ -0,0 +1,22 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.Accessory; + +import java.util.List; + +public record AccessoryResponse( + String slotName, + String accessoryName, + List tunings +) { + public static AccessoryResponse from(Accessory accessory) { + return new AccessoryResponse( + accessory.getSlotName(), + accessory.getAccessoryName(), + accessory.getTunings() == null + ? List.of() + : accessory.getTunings().stream().map(TuningResponse::from).toList() + ); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java index 7eb6384..c4d4492 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java @@ -10,6 +10,7 @@ public record ModificationResponse( String name, String code, List tags, + List accessories, String note, String author, String videoUrl @@ -21,6 +22,9 @@ public record ModificationResponse( modification.getName(), modification.getCode(), modification.getTags(), + modification.getAccessories() == null + ? List.of() + : modification.getAccessories().stream().map(AccessoryResponse::from).toList(), modification.getNote(), modification.getAuthor(), modification.getVideoUrl() diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java new file mode 100644 index 0000000..3f6febf --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java @@ -0,0 +1,16 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.Tuning; + +public record TuningResponse( + String tuningName, + Double tuningValue +) { + public static TuningResponse from(Tuning tuning) { + return new TuningResponse( + tuning.getTuningName(), + tuning.getTuningValue() + ); + } +} + From 7fda77370e4f9a3ae8f8cf624650c307703e8510 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 23 Apr 2026 16:24:41 +0800 Subject: [PATCH 028/117] chore: update artefactVersion to 1.2.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b5a5b32..1235085 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -artefactVersion = 1.1.0 \ No newline at end of file +artefactVersion = 1.2.0 \ No newline at end of file From 70ae945cd2cd3e53499cfe17f463bd1f3c450958 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 14 May 2026 23:56:48 +0800 Subject: [PATCH 029/117] chore: add CLAUDE.md with coding standards and build commands Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 109 ++++++++++++++++++ build.gradle | 37 ++++++ settings.gradle | 1 + .../domain/entity/FirearmTypeConverter.java | 20 ++++ 4 files changed, 167 insertions(+) create mode 100644 CLAUDE.md create mode 100644 build.gradle create mode 100644 settings.gradle create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5767357 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Coding Standards + +- **Style**: Follow the Google Java Coding Style as the foundation. +- **Indentation**: Use 4 spaces — no tabs. +- **Line length**: Maximum 100 characters per line. +- **Comments**: All code comments must use British English spelling (e.g. "colour" not "color", "behaviour" not "behavior", "serialise" not "serialize", "analyse" not "analyze", "traveller" not "traveler"). + +## Build & Test Commands + +```bash +# Build the project (skip tests) +./gradlew build -x test + +# Run all tests +./gradlew test + +# Run a single test class +./gradlew test --tests "com.onixbyte.deltaforceguide.service.PasswordEncoderTest" + +# Run a specific test method +./gradlew test --tests "com.onixbyte.deltaforceguide.service.PasswordEncoderTest.generatePassword" + +# Build the full JAR +./gradlew bootJar +``` + +The project uses Gradle with Java 21 (Amazon Corretto). Tests use JUnit 5 with the Spring Boot test framework, H2 in-memory database for test runtime, and Spring Security test support. Tests require an active `dev` profile. + +## Code Architecture + +**Delta Force Guide Server** — A REST API backend for managing Delta Force game firearm builds/modifications. + +### Package structure + +``` +com.onixbyte.deltaforceguide +├── client/ # External service clients (TokenClient for JWT) +├── config/ # Spring beans: Security, CORS, Cache/Redis, Jackson, MyBatis, Spring Data +├── controller/ # REST controllers (Firearm, Modification, Tag, Auth) +├── domain/ +│ ├── converter/ # JPA attribute converters (FirearmTypeConverter) +│ ├── dto/ # Request/response records (FirearmRequest, ModificationResponse, etc.) +│ └── entity/ # JPA entities (Firearm, Modification, User, Accessory, Tuning) +├── enumeration/ # Enums (FirearmType) +├── exeption/ # BizException (custom runtime exception with HTTP status) +├── filter/ # TokenAuthenticationFilter (JWT auth via OncePerRequestFilter) +├── manager/ # Thin @Transactional wrappers around repositories +├── mapper/ # MyBatis mappers (configured but currently unused) +├── properties/ # @ConfigurationProperties records (Cors, Token, Cookie) +├── repository/ # Spring Data JPA repositories +├── security/ +│ ├── authentication/ # Custom UsernamePasswordAuthentication impl +│ └── provider/ # UsernamePasswordAuthenticationProvider +├── service/ # Business logic layer (FirearmService, ModificationService, AuthService, etc.) +├── shared/ # Constants and utility classes (CookieName, CredentialProvider, JacksonModules) +└── utils/ # Helpers (DateTimeUtil) +``` + +### Key design decisions + +- **JPA + native queries**: Most CRUD uses Spring Data JPA. Native queries (in `ModificationRepository`) handle JSONB tag filtering with Postgres `@>` operator. +- **Custom auth flow**: JWT tokens in httpOnly cookies (`AccessToken`). Spring Security with a custom `UsernamePasswordAuthenticationProvider` and `TokenAuthenticationFilter`. Tokens are auto-renewed within 5 min of expiry. +- **JSONB storage**: `Modification.tags` and `Modification.accessories` (including nested `Tuning` objects) are stored as JSONB columns using Hypersistence Utils `JsonType`. +- **Manager layer**: `UserManager` and `UserCredentialManager` sit between service and repository, adding `@Transactional` boundaries without mixing concerns. +- **DTOs as Java records**: All request/response objects are immutable records with static `from()` factory methods for entity→DTO conversion. +- **Flyway migrations**: SQL migrations in `src/main/resources/db/migration/` — V2 (init), V3 (bullet/damage fields), V4 (user), V5 (accessories JSONB column). + +### Data model + +- `firearm` table: id, name, type (int→FirearmType enum), level, calibre, fire_rate, armour_damage, body_damage, review +- `modification` table: id, firearm_id (FK→firearm), name, code, tags (jsonb), accessories (jsonb), note, author, video_url +- `app_user` table: id, username, email +- `user_credential` table: user_id, provider, credential (hashed) + +### API endpoints + +| Path | Methods | Auth | +|---|---|---| +| `/firearms` | GET, POST | GET public, POST requires auth | +| `/firearms/{id}` | GET, PUT, DELETE | GET public, PUT/DELETE requires auth | +| `/modifications` | GET, POST | GET public, POST requires auth | +| `/modifications/{id}` | GET, PUT, DELETE | GET public, PUT/DELETE requires auth | +| `/modifications/batch` | POST | Requires auth | +| `/modifications/batch-delete` | DELETE | Requires auth | +| `/tags` | GET | Public | +| `/auth/login` | POST | Public | +| `/auth/logout` | POST | Authenticated | + +### Commit convention + +Conventional commits: `feat:`, `chore:`, `fix:`. Messages are in English, present tense imperative style. + +### External dependencies + +- **DB**: PostgreSQL (via Flyway migrations), H2 in test +- **Cache**: Redis (via Spring Cache + RedisTemplate with GenericJackson2JsonRedisSerializer) +- **Auth**: java-jwt (auth0), BCrypt +- **Docs**: springdoc-openapi (Swagger UI) on dev profile +- **Onixbyte internal libs**: version-catalogue, tuple, common-toolbox, math-toolbox, identity-generator, captcha, regions +- **AWS**: S3 SDK + +### Profiles + +- `dev`: Enables Swagger UI, connects to dev DB/Redis at `dfguide.onixbyte.cn`. Config in `config/application-dev.yaml`. +- Default profile: Used for production, no Swagger, connects to production datasource. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..b1aeb5f --- /dev/null +++ b/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.13' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.onixbyte' +version = '0.0.1-SNAPSHOT' +description = 'delta-force-guide-server' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' + runtimeOnly 'org.postgresql:postgresql' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..3da6727 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'delta-force-guide-server' diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java new file mode 100644 index 0000000..03f7574 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java @@ -0,0 +1,20 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import com.onixbyte.deltaforceguide.enumeration.FirearmType; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class FirearmTypeConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(FirearmType attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public FirearmType convertToEntityAttribute(Integer dbData) { + return FirearmType.fromCode(dbData); + } +} + From 559ae349662874e84558d0d883a7084a2fc90222 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 14 May 2026 23:58:25 +0800 Subject: [PATCH 030/117] chore: remove legacy Groovy Gradle files and relocated converter build.gradle and settings.gradle superseded by build.gradle.kts and settings.gradle.kts. FirearmTypeConverter moved to domain/converter/ package. Co-Authored-By: Claude Opus 4.7 --- build.gradle | 37 ------------------- settings.gradle | 1 - .../domain/entity/FirearmTypeConverter.java | 20 ---------- 3 files changed, 58 deletions(-) delete mode 100644 build.gradle delete mode 100644 settings.gradle delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java diff --git a/build.gradle b/build.gradle deleted file mode 100644 index b1aeb5f..0000000 --- a/build.gradle +++ /dev/null @@ -1,37 +0,0 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '3.5.13' - id 'io.spring.dependency-management' version '1.1.7' -} - -group = 'com.onixbyte' -version = '0.0.1-SNAPSHOT' -description = 'delta-force-guide-server' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-cache' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' - runtimeOnly 'org.postgresql:postgresql' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 3da6727..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'delta-force-guide-server' diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java deleted file mode 100644 index 03f7574..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/FirearmTypeConverter.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.onixbyte.deltaforceguide.domain.entity; - -import com.onixbyte.deltaforceguide.enumeration.FirearmType; -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; - -@Converter(autoApply = false) -public class FirearmTypeConverter implements AttributeConverter { - - @Override - public Integer convertToDatabaseColumn(FirearmType attribute) { - return attribute == null ? null : attribute.getCode(); - } - - @Override - public FirearmType convertToEntityAttribute(Integer dbData) { - return FirearmType.fromCode(dbData); - } -} - From 0ae23fa0cb62ff9a7828d3536b00dd5a8a21d2ef Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 15 May 2026 10:41:46 +0800 Subject: [PATCH 031/117] chore: add Claude Code local files to .gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 329367c..0babb7f 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,13 @@ test/ gradle-app.setting .gradletasknamecache +### Claude Code +.claude/settings.local.json +.claude/memory/ +.claude/plans/ +.claude/worktrees/ +.claude/scheduled_tasks.json + # Eclipse Gradle plugin generated files # Eclipse Core .project From 130d360556c66b9ca4a15a68716c59b83d0f387f Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 15 May 2026 11:32:31 +0800 Subject: [PATCH 032/117] feat: add daily password endpoint with Redis caching --- CLAUDE.md | 2 +- .../config/SecurityConfig.java | 3 +- .../controller/DailyPasswordController.java | 27 +++++++ .../domain/dto/DailyPassword.java | 7 ++ .../domain/dto/DailyPasswordData.java | 14 ++++ .../domain/dto/DailyPasswordMetadata.java | 7 ++ .../domain/dto/DailyPasswordResponse.java | 9 +++ .../manager/DailyPasswordManager.java | 76 +++++++++++++++++++ .../service/DailyPasswordService.java | 19 +++++ 9 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java diff --git a/CLAUDE.md b/CLAUDE.md index 5767357..479eb65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ com.onixbyte.deltaforceguide - **JPA + native queries**: Most CRUD uses Spring Data JPA. Native queries (in `ModificationRepository`) handle JSONB tag filtering with Postgres `@>` operator. - **Custom auth flow**: JWT tokens in httpOnly cookies (`AccessToken`). Spring Security with a custom `UsernamePasswordAuthenticationProvider` and `TokenAuthenticationFilter`. Tokens are auto-renewed within 5 min of expiry. - **JSONB storage**: `Modification.tags` and `Modification.accessories` (including nested `Tuning` objects) are stored as JSONB columns using Hypersistence Utils `JsonType`. -- **Manager layer**: `UserManager` and `UserCredentialManager` sit between service and repository, adding `@Transactional` boundaries without mixing concerns. +- **Strict layering**: The call chain must follow `Controller → Service → Manager → Repository/Mapper`. Skipping layers (e.g. Controller calling Manager directly, Service calling Repository directly) is not permitted. Each layer has a distinct responsibility: Controller handles HTTP concerns, Service contains business logic, Manager manages `@Transactional` boundaries and data access coordination, Repository/Mapper handles raw data access. - **DTOs as Java records**: All request/response objects are immutable records with static `from()` factory methods for entity→DTO conversion. - **Flyway migrations**: SQL migrations in `src/main/resources/db/migration/` — V2 (init), V3 (bullet/damage fields), V4 (user), V5 (accessories JSONB column). diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index f77a522..ad9be0e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -57,7 +57,8 @@ public class SecurityConfig { ).permitAll() .requestMatchers(HttpMethod.GET, "/firearms", "/firearms/*", - "/modifications", "/modifications/*" + "/modifications", "/modifications/*", + "/daily-passwords", "/daily-passwords/*" ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java new file mode 100644 index 0000000..f817f66 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java @@ -0,0 +1,27 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse; +import com.onixbyte.deltaforceguide.service.DailyPasswordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "每日密码", description = "获取每日密码信息") +@RestController +@RequestMapping("/daily-passwords") +public class DailyPasswordController { + + private final DailyPasswordService dailyPasswordService; + + public DailyPasswordController(DailyPasswordService dailyPasswordService) { + this.dailyPasswordService = dailyPasswordService; + } + + @Operation(description = "获取当日的每日密码数据,该数据将被缓存一天") + @GetMapping + public DailyPasswordResponse getDailyPassword() { + return dailyPasswordService.getDailyPassword(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java new file mode 100644 index 0000000..2a381ad --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java @@ -0,0 +1,7 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record DailyPassword( + String mapName, + String password +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java new file mode 100644 index 0000000..c685542 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java @@ -0,0 +1,14 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record DailyPasswordData( + String updateDate, + Integer totalCount, + List passwords, + String source, + LocalDateTime lastUpdated, + Long timestamp +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java new file mode 100644 index 0000000..a2fa36b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java @@ -0,0 +1,7 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record DailyPasswordMetadata( + String version, + String author +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java new file mode 100644 index 0000000..7d29a27 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record DailyPasswordResponse( + String status, + String message, + DailyPasswordData data, + DailyPasswordMetadata metadata +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java new file mode 100644 index 0000000..3ec28f9 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java @@ -0,0 +1,76 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.shared.JacksonModules; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Objects; + +@Component +public class DailyPasswordManager { + + private static final String CACHE_KEY_PREFIX = "daily-password:"; + + private final RestClient restClient; + private final RedisTemplate redisTemplate; + + @Autowired + public DailyPasswordManager( + RestClient.Builder restClientBuilder, + RedisTemplate redisTemplate + ) { + var snakeCaseMapper = new ObjectMapper(); + snakeCaseMapper.setPropertyNamingStrategy( + PropertyNamingStrategies.SnakeCaseStrategy.INSTANCE); + snakeCaseMapper.configure( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + snakeCaseMapper.registerModule(JacksonModules.DATE_TIME_MODULE); + + this.restClient = restClientBuilder + .baseUrl("https://tmini.net/api") + .messageConverters(converters -> { + converters.removeIf( + MappingJackson2HttpMessageConverter.class::isInstance); + converters.add( + new MappingJackson2HttpMessageConverter(snakeCaseMapper)); + }) + .build(); + this.redisTemplate = redisTemplate; + } + + public DailyPasswordResponse getDailyPassword() { + var key = CACHE_KEY_PREFIX + LocalDate.now(); + + var cached = redisTemplate.opsForValue().get(key); + if (cached != null) { + return (DailyPasswordResponse) cached; + } + + var response = restClient.get() + .uri((uriBuilder) -> uriBuilder + .path("/sjzmm") + .queryParam("ckey", "") + .queryParam("type", "json") + .build()) + .retrieve() + .body(DailyPasswordResponse.class); + + if (Objects.isNull(response)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "暂无每日密码数据。"); + } + + redisTemplate.opsForValue().set(key, response, Duration.ofDays(1L)); + return response; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java b/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java new file mode 100644 index 0000000..3ad3d4e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java @@ -0,0 +1,19 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse; +import com.onixbyte.deltaforceguide.manager.DailyPasswordManager; +import org.springframework.stereotype.Service; + +@Service +public class DailyPasswordService { + + private final DailyPasswordManager dailyPasswordManager; + + public DailyPasswordService(DailyPasswordManager dailyPasswordManager) { + this.dailyPasswordManager = dailyPasswordManager; + } + + public DailyPasswordResponse getDailyPassword() { + return dailyPasswordManager.getDailyPassword(); + } +} From 6d869d514510b1086a5092a863931ab4baec6d3e Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 15 May 2026 11:35:26 +0800 Subject: [PATCH 033/117] docs: improve API endpoints table formatting in CLAUDE.md --- CLAUDE.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 479eb65..3e567dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,17 +78,17 @@ com.onixbyte.deltaforceguide ### API endpoints -| Path | Methods | Auth | -|---|---|---| -| `/firearms` | GET, POST | GET public, POST requires auth | -| `/firearms/{id}` | GET, PUT, DELETE | GET public, PUT/DELETE requires auth | -| `/modifications` | GET, POST | GET public, POST requires auth | -| `/modifications/{id}` | GET, PUT, DELETE | GET public, PUT/DELETE requires auth | -| `/modifications/batch` | POST | Requires auth | -| `/modifications/batch-delete` | DELETE | Requires auth | -| `/tags` | GET | Public | -| `/auth/login` | POST | Public | -| `/auth/logout` | POST | Authenticated | +| Path | Methods | Auth | +|-------------------------------|------------------|--------------------------------------| +| `/firearms` | GET, POST | GET public, POST requires auth | +| `/firearms/{id}` | GET, PUT, DELETE | GET public, PUT/DELETE requires auth | +| `/modifications` | GET, POST | GET public, POST requires auth | +| `/modifications/{id}` | GET, PUT, DELETE | GET public, PUT/DELETE requires auth | +| `/modifications/batch` | POST | Requires auth | +| `/modifications/batch-delete` | DELETE | Requires auth | +| `/tags` | GET | Public | +| `/auth/login` | POST | Public | +| `/auth/logout` | POST | Authenticated | ### Commit convention From 20d2edc9b132c1e35d6983aaa29acadb1b379986 Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 15 May 2026 11:41:14 +0800 Subject: [PATCH 034/117] feat: use @RequiresAuth annotation instead of manual path listing in security config --- .../config/SecurityConfig.java | 20 +------------------ .../controller/AuthController.java | 2 ++ .../controller/FirearmController.java | 4 ++++ .../controller/ModificationController.java | 6 ++++++ .../security/annotation/RequiresAuth.java | 14 +++++++++++++ 5 files changed, 27 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index ad9be0e..8e8cc50 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -10,7 +10,6 @@ import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthentica import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -43,24 +42,7 @@ public class SecurityConfig { .sessionManagement((customiser) -> customiser .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests((customiser) -> customiser - .requestMatchers("/error", "/error/**").permitAll() - .requestMatchers("/captcha", "/captcha/**").permitAll() - .requestMatchers("/auth/logout").authenticated() - .requestMatchers("/auth/**").permitAll() - .requestMatchers( - "/swagger-ui.html", - "/swagger-ui", - "/swagger-ui/**", - "/v3/api-docs", - "/v3/api-docs.yaml", - "/v3/api-docs/swagger-config" - ).permitAll() - .requestMatchers(HttpMethod.GET, - "/firearms", "/firearms/*", - "/modifications", "/modifications/*", - "/daily-passwords", "/daily-passwords/*" - ).permitAll() - .anyRequest().authenticated() + .anyRequest().permitAll() ) .addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class) .build(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index 6d7e4e5..86229aa 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -3,6 +3,7 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.dto.UserResponse; import com.onixbyte.deltaforceguide.client.TokenClient; +import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.service.AuthService; import com.onixbyte.deltaforceguide.service.CookieService; import com.onixbyte.deltaforceguide.shared.CookieName; @@ -45,6 +46,7 @@ public class AuthController { .body(UserResponse.from(user)); } + @RequiresAuth @Operation(description = "退出登录") @PostMapping("/logout") public ResponseEntity logout() { diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index 817291e..7d95b69 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.dto.FirearmRequest; import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.enumeration.FirearmType; +import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.service.FirearmService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -44,17 +45,20 @@ public class FirearmController { return firearmService.queryById(id); } + @RequiresAuth @PostMapping public FirearmResponse addFirearm(@Validated @RequestBody FirearmRequest request) { return firearmService.addFirearm(request); } + @RequiresAuth @Operation(description = "更新指定武器的数据") @PutMapping("/{id}") public FirearmResponse updateFirearm(@PathVariable Long id, @Validated @RequestBody FirearmRequest request) { return firearmService.updateFirearm(id, request); } + @RequiresAuth @Operation(description = "删除指定武器的数据") @DeleteMapping("/{id}") public void deleteFirearm(@PathVariable Long id) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index 1d3f90c..b7c7a8d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchCreateRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.service.ModificationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -57,30 +58,35 @@ public class ModificationController { return modificationService.queryById(id); } + @RequiresAuth @Operation(description = "创建改装") @PostMapping public ModificationResponse create(@Valid @RequestBody ModificationRequest request) { return modificationService.create(request); } + @RequiresAuth @Operation(description = "批量创建改装") @PostMapping("/batch") public List batchCreate(@Valid @RequestBody ModificationBatchCreateRequest request) { return modificationService.batchCreate(request.modifications()); } + @RequiresAuth @Operation(description = "修改指定改装") @PutMapping("/{id}") public ModificationResponse update(@PathVariable Long id, @Valid @RequestBody ModificationRequest request) { return modificationService.update(id, request); } + @RequiresAuth @Operation(description = "删除指定改装") @DeleteMapping("/{id}") public void delete(@PathVariable Long id) { modificationService.delete(id); } + @RequiresAuth @Operation(description = "批量删除改装") @DeleteMapping("/batch-delete") @Validated diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java new file mode 100644 index 0000000..f022be3 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java @@ -0,0 +1,14 @@ +package com.onixbyte.deltaforceguide.security.annotation; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("isAuthenticated()") +public @interface RequiresAuth { +} From 24b7913908ea1c68fb19247131430f65604b0d9e Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 18 May 2026 17:07:34 +0800 Subject: [PATCH 035/117] chore: add GitLab CI pipeline with build, container registry push, and deploy stages --- .gitlab-ci.yml | 157 +++++++++++++++++++++++++++++++++++++++++++++++++ Dockerfile.ci | 7 +++ 2 files changed, 164 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile.ci diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6994f62 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,157 @@ +stages: + - package + - build-image + - push + - deploy + +variables: + # ---------- Gradle ---------- + GRADLE_IMAGE: gradle:8.14.4-jdk21 + GRADLE_USER_HOME: ${CI_PROJECT_DIR}/.gradle + + # ---------- Docker ---------- + DOCKER_IMAGE: docker:27.5.1 + DOCKER_SERVICE: docker:27.5.1-dind + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + + # ---------- Application ---------- + APP_NAME: delta-force-guide-server + + # ---------- Image tags ---------- + IMAGE_TAG: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA} + LATEST_TAG: ${CI_REGISTRY_IMAGE}:latest + + # ---------- CI Dockerfile ---------- + CI_DOCKERFILE: Dockerfile.ci + +cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - .gradle/wrapper + - .gradle/caches + policy: pull-push + +# ==================================================================== +# Reusable template for Docker jobs +# ==================================================================== +.docker: + image: ${DOCKER_IMAGE} + services: + - name: ${DOCKER_SERVICE} + command: ["--tls=false"] + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + +# Trigger the pipeline for MRs, the default branch, and tags +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: '$CI_COMMIT_TAG' + +# ==================================================================== +# Stage 1 — Package: build the JAR with Gradle +# ==================================================================== +package: + stage: package + image: ${GRADLE_IMAGE} + script: + - ./gradlew build + artifacts: + name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}" + paths: + - build/libs/*.jar + expire_in: 1 hour + +# ==================================================================== +# Stage 2 — Build Docker image using the pre-built JAR artifact +# ==================================================================== +build-image: + stage: build-image + extends: .docker + script: + # Resolve the actual JAR path + - JAR_FILE=$(ls build/libs/delta-force-guide-server-*.jar | head -1) + - echo "Packaging JAR: ${JAR_FILE}" + + # Build image with the CI-specific single-stage Dockerfile + - | + docker build \ + --build-arg JAR_FILE="${JAR_FILE}" \ + -f ${CI_DOCKERFILE} \ + -t ${IMAGE_TAG} \ + -t ${LATEST_TAG} \ + . + + # Save the image as a CI artefact for the next stage + - docker save ${IMAGE_TAG} ${LATEST_TAG} > image.tar + artifacts: + paths: + - image.tar + expire_in: 1 hour + needs: + - package + +# ==================================================================== +# Stage 3 — Push image to GitLab Container Registry +# ==================================================================== +push: + stage: push + extends: .docker + script: + - docker load < image.tar + - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY} + - docker push ${IMAGE_TAG} + - docker push ${LATEST_TAG} + needs: + - build-image + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: '$CI_COMMIT_TAG' + +# ==================================================================== +# Stage 4 — Deploy on the target server via SSH +# ==================================================================== +deploy: + stage: deploy + image: alpine:latest + before_script: + - apk add --no-cache openssh-client + - eval "$(ssh-agent -s)" + - echo "${DEPLOY_SSH_PRIVATE_KEY}" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + script: + - | + ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} " + set -e + + echo '=== Pulling image ===' + echo ${CI_REGISTRY_PASSWORD} | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY} + docker pull ${IMAGE_TAG} + + echo '=== Stopping old container ===' + docker stop ${APP_NAME} || true + docker rm ${APP_NAME} || true + + echo '=== Starting new container ===' + docker run -d \ + --name ${APP_NAME} \ + --restart unless-stopped \ + -p ${DEPLOY_PORT:-8080}:8080 \ + ${IMAGE_TAG} + + echo '=== Cleaning up old images ===' + docker image prune -f + + echo '=== Deployment complete ===' + " + needs: + - push + environment: + name: production + url: http://${DEPLOY_HOST}:${DEPLOY_PORT:-8080} + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 0000000..5b0e97c --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,7 @@ +FROM amazoncorretto:21-alpine +WORKDIR /app + +ARG JAR_FILE +COPY ${JAR_FILE} app.jar + +ENTRYPOINT ["java", "-jar", "app.jar"] From b94a09691df5590c3445dbeed6fd80a81b19f722 Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 19 May 2026 10:38:11 +0800 Subject: [PATCH 036/117] chore: add GitHub Actions workflow for release-based build and deploy --- .github/workflows/build-and-deploy.yml | 127 +++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .github/workflows/build-and-deploy.yml diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml new file mode 100644 index 0000000..5e09721 --- /dev/null +++ b/.github/workflows/build-and-deploy.yml @@ -0,0 +1,127 @@ +name: Build and Deploy + +on: + release: + types: [published] + +env: + APP_NAME: delta-force-guide-server + IMAGE_REGISTRY: ${{ vars.GITLAB_REGISTRY }} + IMAGE_NAME: ${{ vars.GITLAB_IMAGE_NAME }} + +jobs: + # ================================================================ + # Job 1 — Package: build the JAR with Gradle + # ================================================================ + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 (Corretto) + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: corretto + cache: gradle + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew build + + - name: Upload JAR artifact + uses: actions/upload-artifact@v4 + with: + name: app-jar + path: build/libs/delta-force-guide-server-*.jar + retention-days: 1 + + # ================================================================ + # Job 2 — Build & push Docker image to GitHub Container Registry + # ================================================================ + build-and-push: + needs: package + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Download JAR artifact + uses: actions/download-artifact@v4 + with: + name: app-jar + path: build/libs + + - name: Resolve JAR file path + id: jar + run: echo "file=$(ls build/libs/delta-force-guide-server-*.jar | head -1)" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitLab Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ vars.GITLAB_REGISTRY_USER }} + password: ${{ secrets.GITLAB_REGISTRY_PASSWORD }} + + - name: Generate image tags + id: meta + run: | + echo "version=${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + echo "latest=${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.ci + build-args: JAR_FILE=${{ steps.jar.outputs.file }} + push: true + tags: | + ${{ steps.meta.outputs.version }} + ${{ steps.meta.outputs.latest }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ================================================================ + # Job 3 — Deploy on the target server via SSH + # ================================================================ + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -e + + echo '=== Pulling image ===' + echo '${{ secrets.GITLAB_REGISTRY_PASSWORD }}' | docker login ${{ env.IMAGE_REGISTRY }} \ + -u ${{ vars.GITLAB_REGISTRY_USER }} --password-stdin + docker pull ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} + + echo '=== Stopping old container ===' + docker stop ${{ env.APP_NAME }} || true + docker rm ${{ env.APP_NAME }} || true + + echo '=== Starting new container ===' + docker run -d \ + --name ${{ env.APP_NAME }} \ + --restart unless-stopped \ + -p ${DEPLOY_PORT:-8080}:8080 \ + ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} + + echo '=== Cleaning up old images ===' + docker image prune -f + + echo '=== Deployment complete ===' From 491be4f4dd765f47ad95023d98d9c8be539b2bc9 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 09:05:13 +0800 Subject: [PATCH 037/117] chore: simplify GitLab CI to release-only workflow with tag-triggered pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the full CI pipeline (build → image → push → SSH deploy on every branch) with a focused release workflow: build JAR on tag push, package Docker image tagged with the release tag, and push to registry.onixbyte.cn. --- .gitlab-ci.yml | 191 +++++++++++++------------------------------------ 1 file changed, 48 insertions(+), 143 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6994f62..35ffefa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,157 +1,62 @@ +variables: + REGISTRY: registry.onixbyte.cn + IMAGE_NAME: delta-force-guide + GRADLE_OPTS: -Dorg.gradle.daemon=false + stages: + - build - package - - build-image - - push - deploy -variables: - # ---------- Gradle ---------- - GRADLE_IMAGE: gradle:8.14.4-jdk21 - GRADLE_USER_HOME: ${CI_PROJECT_DIR}/.gradle - - # ---------- Docker ---------- - DOCKER_IMAGE: docker:27.5.1 - DOCKER_SERVICE: docker:27.5.1-dind - DOCKER_HOST: tcp://docker:2375 - DOCKER_TLS_CERTDIR: "" - - # ---------- Application ---------- - APP_NAME: delta-force-guide-server - - # ---------- Image tags ---------- - IMAGE_TAG: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA} - LATEST_TAG: ${CI_REGISTRY_IMAGE}:latest - - # ---------- CI Dockerfile ---------- - CI_DOCKERFILE: Dockerfile.ci - -cache: - key: ${CI_COMMIT_REF_SLUG} - paths: - - .gradle/wrapper - - .gradle/caches - policy: pull-push - -# ==================================================================== -# Reusable template for Docker jobs -# ==================================================================== -.docker: - image: ${DOCKER_IMAGE} - services: - - name: ${DOCKER_SERVICE} - command: ["--tls=false"] - variables: - DOCKER_HOST: tcp://docker:2375 - DOCKER_TLS_CERTDIR: "" - -# Trigger the pipeline for MRs, the default branch, and tags -workflow: - rules: - - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - - if: '$CI_COMMIT_TAG' - -# ==================================================================== -# Stage 1 — Package: build the JAR with Gradle -# ==================================================================== -package: - stage: package - image: ${GRADLE_IMAGE} +build: + stage: build + image: amazoncorretto:21-alpine + cache: + key: gradle + paths: + - .gradle/wrapper + - .gradle/caches + before_script: + - chmod +x gradlew script: - - ./gradlew build + - ./gradlew bootJar -x test artifacts: - name: "${CI_JOB_NAME}-${CI_COMMIT_SHORT_SHA}" paths: - build/libs/*.jar - expire_in: 1 hour - -# ==================================================================== -# Stage 2 — Build Docker image using the pre-built JAR artifact -# ==================================================================== -build-image: - stage: build-image - extends: .docker - script: - # Resolve the actual JAR path - - JAR_FILE=$(ls build/libs/delta-force-guide-server-*.jar | head -1) - - echo "Packaging JAR: ${JAR_FILE}" - - # Build image with the CI-specific single-stage Dockerfile - - | - docker build \ - --build-arg JAR_FILE="${JAR_FILE}" \ - -f ${CI_DOCKERFILE} \ - -t ${IMAGE_TAG} \ - -t ${LATEST_TAG} \ - . - - # Save the image as a CI artefact for the next stage - - docker save ${IMAGE_TAG} ${LATEST_TAG} > image.tar - artifacts: - paths: - - image.tar - expire_in: 1 hour - needs: - - package - -# ==================================================================== -# Stage 3 — Push image to GitLab Container Registry -# ==================================================================== -push: - stage: push - extends: .docker - script: - - docker load < image.tar - - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY} - - docker push ${IMAGE_TAG} - - docker push ${LATEST_TAG} - needs: - - build-image + expire_in: 30 min rules: - - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - - if: '$CI_COMMIT_TAG' + - if: $CI_COMMIT_TAG + +package: + stage: package + image: docker:27 + services: + - docker:27-dind + needs: + - build + script: + - JAR_FILE=$(find build/libs -name '*.jar' | head -1) + - echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE" + - docker build + -f Dockerfile.ci + --build-arg JAR_FILE="$JAR_FILE" + -t "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" + . + - docker tag "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" "$REGISTRY/$IMAGE_NAME:latest" + rules: + - if: $CI_COMMIT_TAG -# ==================================================================== -# Stage 4 — Deploy on the target server via SSH -# ==================================================================== deploy: stage: deploy - image: alpine:latest - before_script: - - apk add --no-cache openssh-client - - eval "$(ssh-agent -s)" - - echo "${DEPLOY_SSH_PRIVATE_KEY}" | tr -d '\r' | ssh-add - - - mkdir -p ~/.ssh - - chmod 700 ~/.ssh - script: - - | - ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} " - set -e - - echo '=== Pulling image ===' - echo ${CI_REGISTRY_PASSWORD} | docker login -u ${CI_REGISTRY_USER} --password-stdin ${CI_REGISTRY} - docker pull ${IMAGE_TAG} - - echo '=== Stopping old container ===' - docker stop ${APP_NAME} || true - docker rm ${APP_NAME} || true - - echo '=== Starting new container ===' - docker run -d \ - --name ${APP_NAME} \ - --restart unless-stopped \ - -p ${DEPLOY_PORT:-8080}:8080 \ - ${IMAGE_TAG} - - echo '=== Cleaning up old images ===' - docker image prune -f - - echo '=== Deployment complete ===' - " + image: docker:27 + services: + - docker:27-dind needs: - - push - environment: - name: production - url: http://${DEPLOY_HOST}:${DEPLOY_PORT:-8080} + - package + script: + - echo "Pushing image $REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" + - docker login "$REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" + - docker push "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" + - docker push "$REGISTRY/$IMAGE_NAME:latest" rules: - - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: $CI_COMMIT_TAG From b60cd36535905c2871e2a0e696909566de83a647 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 10:17:33 +0800 Subject: [PATCH 038/117] chore: switch CI to Docker socket binding and add artefact version parameter Replace DinD services with unix:///var/run/docker.sock socket binding to fix "Cannot connect to Docker daemon" errors. Add -PartefactVersion parameter to Gradle build for release version tracking. --- .gitlab-ci.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 35ffefa..d213467 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ variables: REGISTRY: registry.onixbyte.cn IMAGE_NAME: delta-force-guide GRADLE_OPTS: -Dorg.gradle.daemon=false + DOCKER_HOST: unix:///var/run/docker.sock stages: - build @@ -19,7 +20,7 @@ build: before_script: - chmod +x gradlew script: - - ./gradlew bootJar -x test + - ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG" artifacts: paths: - build/libs/*.jar @@ -30,8 +31,6 @@ build: package: stage: package image: docker:27 - services: - - docker:27-dind needs: - build script: @@ -49,8 +48,6 @@ package: deploy: stage: deploy image: docker:27 - services: - - docker:27-dind needs: - package script: From d19b7f556334f96a41cb0adac498488aff01cd6b Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 11:05:25 +0800 Subject: [PATCH 039/117] fix: allow jar files to be copied in Docker build context Add '!build/libs/*.jar' to .dockerignore so that Docker build can access the build artifacts in the package stage. --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index 595848f..f740bfc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,6 @@ delta-force-guide-server.iml build/ +!.gitlab-ci.yml +!build/libs/*.jar .idea/ .gradle \ No newline at end of file From 0f1093774fea52982bbd45db742429a41774e172 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 11:47:22 +0800 Subject: [PATCH 040/117] fix: use GitLab predefined environment variables for container registry Replace custom registry variables with GitLab's predefined CI_REGISTRY, CI_REGISTRY_IMAGE, and CI_REGISTRY_USER to ensure the built-in CI_JOB_TOKEN has correct push permissions. --- .gitlab-ci.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d213467..ed06175 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,4 @@ variables: - REGISTRY: registry.onixbyte.cn - IMAGE_NAME: delta-force-guide GRADLE_OPTS: -Dorg.gradle.daemon=false DOCKER_HOST: unix:///var/run/docker.sock @@ -39,9 +37,9 @@ package: - docker build -f Dockerfile.ci --build-arg JAR_FILE="$JAR_FILE" - -t "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" + -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" . - - docker tag "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" "$REGISTRY/$IMAGE_NAME:latest" + - docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest" rules: - if: $CI_COMMIT_TAG @@ -51,9 +49,9 @@ deploy: needs: - package script: - - echo "Pushing image $REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" - - docker login "$REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" - - docker push "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" - - docker push "$REGISTRY/$IMAGE_NAME:latest" + - echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" + - docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" + - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" + - docker push "$CI_REGISTRY_IMAGE:latest" rules: - if: $CI_COMMIT_TAG From bd2748e25ce6891ccfb010afe8c57ee685db6dc5 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 13:58:14 +0800 Subject: [PATCH 041/117] fix: disable provenance in docker build to fix GitLab Registry 0B display Add `--provenance=false` flag to `docker build` command. This stops Docker BuildKit from generating OCI Referrers/attestations, which are not correctly parsed by GitLab Container Registry and cause the UI to display 0B size and "missing manifest digest" errors. --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ed06175..8dc1b6b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -35,6 +35,7 @@ package: - JAR_FILE=$(find build/libs -name '*.jar' | head -1) - echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE" - docker build + --provenance=false -f Dockerfile.ci --build-arg JAR_FILE="$JAR_FILE" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" From 5cea825bc03c5e6751a27a4df4ac61d00fc621d4 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 14:39:33 +0800 Subject: [PATCH 042/117] chore: remove gitlab artifacts to avoid slow uploads Remove artifacts uploading from the build stage. Since we use a shared docker socket on the same runner host, the package stage can access the locally built jar file directly without needing gitlab coordinator upload/download. --- .gitlab-ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8dc1b6b..eb383cd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,10 +19,6 @@ build: - chmod +x gradlew script: - ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG" - artifacts: - paths: - - build/libs/*.jar - expire_in: 30 min rules: - if: $CI_COMMIT_TAG From e7da3a76b7670e500e049f6ebde4f1aa8f7185cc Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 14:45:26 +0800 Subject: [PATCH 043/117] ci: recover artefact uploading --- .gitlab-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index eb383cd..d8579d3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,6 +19,9 @@ build: - chmod +x gradlew script: - ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG" + artifacts: + paths: + - build/libs/*.jar rules: - if: $CI_COMMIT_TAG From e4dca61f9849b06a19be950992698b91d3aeef8c Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 15:43:23 +0800 Subject: [PATCH 044/117] chore: merge CI stages into a single release job to optimize speed Merge build, package, and deploy stages into a single 'release' job. By building the jar and running docker commands in the same container using local docker socket, we completely bypass the need for GitLab artifact uploading/downloading. This significantly reduces network overhead and speeds up release deployment. --- .gitlab-ci.yml | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d8579d3..fc0e018 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,12 +3,10 @@ variables: DOCKER_HOST: unix:///var/run/docker.sock stages: - - build - - package - - deploy + - release -build: - stage: build +release: + stage: release image: amazoncorretto:21-alpine cache: key: gradle @@ -17,20 +15,9 @@ build: - .gradle/caches before_script: - chmod +x gradlew + - apk add --no-cache docker-cli script: - ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG" - artifacts: - paths: - - build/libs/*.jar - rules: - - if: $CI_COMMIT_TAG - -package: - stage: package - image: docker:27 - needs: - - build - script: - JAR_FILE=$(find build/libs -name '*.jar' | head -1) - echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE" - docker build @@ -40,15 +27,6 @@ package: -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" . - docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest" - rules: - - if: $CI_COMMIT_TAG - -deploy: - stage: deploy - image: docker:27 - needs: - - package - script: - echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" - docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" From a8ff1cabad44e23055ad9581b7df98cabea8cce3 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 15:52:54 +0800 Subject: [PATCH 045/117] chore: rewrite GitHub Actions to build, publish release JAR, and push image to GHCR Update build-and-deploy.yml workflow to: 1. Run single job 'build-and-release' to bypass artifact transfers. 2. Build JAR with -PartefactVersion parameter. 3. Upload the compiled JAR asset directly into GitHub Releases. 4. Build and push the Docker image directly to GitHub Container Registry (ghcr.io). --- .github/workflows/build-and-deploy.yml | 108 ++++++++----------------- 1 file changed, 33 insertions(+), 75 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 5e09721..e275d22 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -6,15 +6,16 @@ on: env: APP_NAME: delta-force-guide-server - IMAGE_REGISTRY: ${{ vars.GITLAB_REGISTRY }} - IMAGE_NAME: ${{ vars.GITLAB_IMAGE_NAME }} jobs: # ================================================================ - # Job 1 — Package: build the JAR with Gradle + # Single Job: Build, Upload JAR to Release, and Push to GHCR # ================================================================ - package: + build-and-release: runs-on: ubuntu-latest + permissions: + contents: write + packages: write steps: - uses: actions/checkout@v4 @@ -28,54 +29,47 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + # 使用 Release Tag 做为 Gradle 属性传入 - name: Build with Gradle - run: ./gradlew build - - - name: Upload JAR artifact - uses: actions/upload-artifact@v4 - with: - name: app-jar - path: build/libs/delta-force-guide-server-*.jar - retention-days: 1 - - # ================================================================ - # Job 2 — Build & push Docker image to GitHub Container Registry - # ================================================================ - build-and-push: - needs: package - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Download JAR artifact - uses: actions/download-artifact@v4 - with: - name: app-jar - path: build/libs + run: ./gradlew bootJar -x test -PartefactVersion="${{ github.event.release.tag_name }}" - name: Resolve JAR file path id: jar - run: echo "file=$(ls build/libs/delta-force-guide-server-*.jar | head -1)" >> "$GITHUB_OUTPUT" + run: | + JAR_PATH=$(find build/libs -name '*.jar' | head -1) + echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT" + + # 上传 JAR 包到 GitHub Release 中 + - name: Upload JAR to GitHub Release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ steps.jar.outputs.file }} + asset_name: ${{ github.event.repository.name }}-${{ github.event.release.tag_name }}.jar + tag: ${{ github.event.release.tag_name }} + overwrite: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitLab Container Registry + # 登录到 GitHub Container Registry (GHCR) + - name: Log in to GHCR uses: docker/login-action@v3 with: - registry: ${{ env.IMAGE_REGISTRY }} - username: ${{ vars.GITLAB_REGISTRY_USER }} - password: ${{ secrets.GITLAB_REGISTRY_PASSWORD }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # 镜像打标签准备 - name: Generate image tags id: meta run: | - echo "version=${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" - echo "latest=${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" + OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + REPO_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') + echo "tag_version=ghcr.io/$OWNER_LC/$REPO_LC:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + echo "tag_latest=ghcr.io/$OWNER_LC/$REPO_LC:latest" >> "$GITHUB_OUTPUT" + # 构建并上传镜像到 GHCR - name: Build and push Docker image uses: docker/build-push-action@v6 with: @@ -84,44 +78,8 @@ jobs: build-args: JAR_FILE=${{ steps.jar.outputs.file }} push: true tags: | - ${{ steps.meta.outputs.version }} - ${{ steps.meta.outputs.latest }} + ${{ steps.meta.outputs.tag_version }} + ${{ steps.meta.outputs.tag_latest }} cache-from: type=gha cache-to: type=gha,mode=max - # ================================================================ - # Job 3 — Deploy on the target server via SSH - # ================================================================ - deploy: - needs: build-and-push - runs-on: ubuntu-latest - steps: - - name: Deploy via SSH - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.DEPLOY_HOST }} - username: ${{ secrets.DEPLOY_USER }} - key: ${{ secrets.DEPLOY_SSH_KEY }} - script: | - set -e - - echo '=== Pulling image ===' - echo '${{ secrets.GITLAB_REGISTRY_PASSWORD }}' | docker login ${{ env.IMAGE_REGISTRY }} \ - -u ${{ vars.GITLAB_REGISTRY_USER }} --password-stdin - docker pull ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} - - echo '=== Stopping old container ===' - docker stop ${{ env.APP_NAME }} || true - docker rm ${{ env.APP_NAME }} || true - - echo '=== Starting new container ===' - docker run -d \ - --name ${{ env.APP_NAME }} \ - --restart unless-stopped \ - -p ${DEPLOY_PORT:-8080}:8080 \ - ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} - - echo '=== Cleaning up old images ===' - docker image prune -f - - echo '=== Deployment complete ===' From e2a40795c59d034f57f0c796e58d9bb0cd187b03 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 25 May 2026 16:01:19 +0800 Subject: [PATCH 046/117] chore: opt-in to Node.js 24 for GitHub Actions to clear deprecation warning Set the environment variable FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to true to force the workflow and runner to execute all JavaScript actions using Node.js 24. This resolves the future deprecation warning for Node.js 20. --- .github/workflows/build-and-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index e275d22..116f12f 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -6,6 +6,7 @@ on: env: APP_NAME: delta-force-guide-server + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: # ================================================================ From 0671937ecdbb8017dfa537e3f13e40bf5f46d169 Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 26 May 2026 10:18:27 +0800 Subject: [PATCH 047/117] feat: add versioning entrypoint --- .../deltaforceguide/config/AppConfig.java | 10 ++++++++ .../controller/VersionController.java | 24 +++++++++++++++++++ .../deltaforceguide/manager/AppManager.java | 23 ++++++++++++++++++ .../properties/AppProperties.java | 9 +++++++ .../deltaforceguide/service/AppService.java | 18 ++++++++++++++ 5 files changed, 84 insertions(+) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/AppService.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java new file mode 100644 index 0000000..32ec283 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -0,0 +1,10 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.properties.AppProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(AppProperties.class) +public class AppConfig { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java new file mode 100644 index 0000000..a73b43c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java @@ -0,0 +1,24 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.service.AppService; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/versions") +public class VersionController { + + private final AppService appService; + + public VersionController(AppService appService) { + this.appService = appService; + } + + @Operation(description = "获取当前应用版本号") + @GetMapping + public String getVersion() { + return appService.getVersion(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java new file mode 100644 index 0000000..64f8ddd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java @@ -0,0 +1,23 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.properties.AppProperties; +import org.springframework.stereotype.Component; + +@Component +public class AppManager { + + private final AppProperties appProperties; + + public AppManager(AppProperties appProperties) { + this.appProperties = appProperties; + } + + /** + * Retrieves the application version. + * + * @return the version string of this application + */ + public String getVersion() { + return appProperties.version(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java new file mode 100644 index 0000000..79b056c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.common") +public record AppProperties( + String version +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AppService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AppService.java new file mode 100644 index 0000000..68f50bd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AppService.java @@ -0,0 +1,18 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.manager.AppManager; +import org.springframework.stereotype.Service; + +@Service +public class AppService { + + private final AppManager appManager; + + public AppService(AppManager appManager) { + this.appManager = appManager; + } + + public String getVersion() { + return appManager.getVersion(); + } +} From d27f6455d8810e1013cb0bf76321cba55b7ce4b6 Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 26 May 2026 10:41:37 +0800 Subject: [PATCH 048/117] docs: add README, LICENCE, and production config template - Add MIT LICENCE file - Add comprehensive README with tech stack, API overview, and architecture docs - Add example production configuration template - Remove gradle.properties in favour of build-time version injection @ --- LICENCE | 21 ++++ README.md | 150 +++++++++++++++++++++++++++ config/application-prod.yaml.example | 55 ++++++++++ gradle.properties | 1 - 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 LICENCE create mode 100644 README.md create mode 100644 config/application-prod.yaml.example delete mode 100644 gradle.properties diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..73c61fd --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT Licence + +Copyright (c) 2026 OnixByte + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..13455ae --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# Delta Force Guide Server + +REST API backend for managing **Delta Force** game firearm builds and modifications. Provides endpoints to browse, create, and share weapon configurations including attachments, tuning setups, tags, and build reviews. + + +## Tech Stack + +| Layer | Technology | +|-----------|------------------------------------------------------------| +| Language | Java 21 (Amazon Corretto) | +| Framework | Spring Boot 3.x (Web, Security, Data JPA, Cache, Actuator) | +| Build | Gradle (Kotlin DSL) | +| Database | PostgreSQL (Flyway migrations, JSONB columns) | +| Cache | Redis (2-hour TTL) | +| Auth | Custom JWT via httpOnly cookies + BCrypt | +| API Docs | springdoc-openapi (Swagger UI, dev profile only) | +| Container | Multi-stage Docker image (Amazon Corretto 21 → Alpine) | +| CI/CD | GitHub Actions — builds on release publish | + + +## Quick Start + +### Prerequisites + +- JDK 21 (Amazon Corretto recommended) +- PostgreSQL +- Redis + +### Configure + +Copy and customise the development config: + +```bash +cp config/application-prod.yaml.example \ + config/application-dev.yaml +``` + +Fill in your datasource and Redis connection details, then place the production config at `config/`, then enable it by environment variable `SPRING_PROFILES_ACTIVE`. + +### Build + +```bash +# Compile (skip tests) +./gradlew build -x test + +# Run all tests +./gradlew test + +# Build executable JAR +./gradlew bootJar +``` + +### Run + +```bash +SPRING_PROFILES_ACTIVE=prod java -jar build/libs/delta-force-guide-server-$version.jar +``` + +Swagger UI is available at `http://localhost:8080/swagger-ui.html` when the `dev` profile is active. + +## Data Model + +- **Firearm** — weapon base stats (name, type, level, calibre, fire rate, armour/body damage, review) +- **Modification** — a build attached to a firearm, with name, code, tags (JSONB), accessories including nested tuning objects (JSONB), author notes, and video links +- **App User** — registered user (username, email) with hashed credentials via BCrypt + +Tags and accessories are stored as PostgreSQL JSONB columns using [Hypersistence Utils](https://github.com/vladmihalcea/hypersistence-utils), enabling flexible per-build metadata and filtering with the `@>` operator. + + +## Architecture + +``` +Controller → Service → Manager → Repository / Mapper + (HTTP) (logic) (@Transactional) (data access) +``` + +The call chain is strictly enforced — skipping layers is not permitted. All request/response objects are Java records with static `from()` factory methods for entity-to-DTO conversion. + + +## Docker + +```bash +# Build image +docker pull registry.onixbyte.cn/onixbyte/delta-force-guide-server:latest + +# Run container +docker run -p 8080:8080 \ + -v /path/to/config:/app/config \ + -e SPRING_PROFILES_ACTIVE=$your_active_profiles + delta-force-guide-server +``` + +Pre-built images are published to **Self-hosted GitLab Container Registry** (`registry.onixbyte.cn/onixbyte/delta-force-guide-server`) on every release. + + +## CI/CD + +GitLab CI triggers on **tags**. The pipeline: + +1. Builds the boot JAR with the release tag as the version +2. Uploads the JAR as a release asset +3. Builds and pushes a multi-arch Docker image to GHCR tagged with both `:latest` and `:` + +No tests are run in CI by design — tests are expected to pass locally before a release is cut. + + +## Profiles + +| Profile | Purpose | +|---------|-----------------------------------------------------------------------| +| `dev` | Enables Swagger UI, connects to dev DB/Redis at `dfguide.onixbyte.cn` | +| default | Production mode, no Swagger, uses production datasource | + + +## Project Structure + +``` +src/main/java/com/onixbyte/deltaforceguide/ +├── client/ External service HTTP clients +├── config/ Spring bean definitions (Security, CORS, Cache, Jackson, MyBatis) +├── controller/ REST controllers +├── domain/ +│ ├── converter/ JPA attribute converters +│ ├── dto/ Request/response records +│ └── entity/ JPA entities +├── enumeration/ Enums (FirearmType) +├── exception/ Custom BizException with HTTP status mapping +├── filter/ TokenAuthenticationFilter (JWT through OncePerRequestFilter) +├── manager/ @Transactional wrappers around repositories +├── mapper/ MyBatis mapper interfaces (reserved for future use) +├── properties/ @ConfigurationProperties records +├── repository/ Spring Data JPA repositories +├── security/ +│ ├── authentication/ Custom UsernamePasswordAuthentication +│ └── provider/ UsernamePasswordAuthenticationProvider +├── service/ Business logic +├── shared/ Constants and utility classes +└── utils/ General-purpose helpers +``` + + +## Versioning + +The application exposes its version at `/versions` (GET). + + +## Licence + +This project is licensed under the [MIT Licence](LICENCE). Copyright © 2026 OnixByte. + diff --git a/config/application-prod.yaml.example b/config/application-prod.yaml.example new file mode 100644 index 0000000..ae00f78 --- /dev/null +++ b/config/application-prod.yaml.example @@ -0,0 +1,55 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/dfguide_dev + username: postgres + password: 123456 + driver-class-name: org.postgresql.Driver + data: + redis: + host: localhost + port: 6379 + database: 0 + # password: 6hLFVqfGPviTYukn # Uncomment if password is necessary + +logging: + pattern: + # dateformat: dd MMM yyyy HH:mm:ss.SSS # Modify this for custom date format. + +app: + common: + version: 1.3.0.8-dev # Application version, you can change to any version you like, used for communication with frontend. + cors: + allowed-origins: # Cross-origin allowed origins + - "http://localhost:5173" # Dev server for vite. + - "http://localhost:4173" # Preview server for vite. + allow-credentials: true # Must be set to `true` since we are using cookie. You can change it only when you have modified how this application executing authentication. + allow-private-network: false + allowed-headers: + - "Content-Type" + - "Authorization" + allowed-methods: + - GET + - POST + - PUT + - PATCH + - DELETE + exposed-headers: + - "Content-Type" + - "Authorization" + max-age: PT2H + jwt: + issuer: dfguide.local # Issuer host + secret: qwertyuiopasdfghjklzxcvbnm123456 # JWT singing secret, a 32-byte long or longer string is recommended + valid-time: PT2H # JWT valid duration + cookie: # Cookie settings. + http-only: true + secure: false + same-site: lax + path: '/' + max-age: PT2H + +springdoc: + api-docs: + enabled: true # Set to `false` if you do not need api docs (recommended in production mode). + swagger-ui: + enabled: true # Set to `false` if you do not need swagger ui (recommended in production mode). \ No newline at end of file diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index 1235085..0000000 --- a/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -artefactVersion = 1.2.0 \ No newline at end of file From 9fe292963c6e3d8d4185eab5328850167b90fa1b Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 26 May 2026 10:47:09 +0800 Subject: [PATCH 049/117] fix: restrict GitLab CI to main branch and drop --provenance flag - Add main branch guard to pipeline rules - Remove unsupported --provenance=false flag incompatible with legacy builder --- .gitlab-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fc0e018..477fe78 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,7 +21,6 @@ release: - JAR_FILE=$(find build/libs -name '*.jar' | head -1) - echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE" - docker build - --provenance=false -f Dockerfile.ci --build-arg JAR_FILE="$JAR_FILE" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" @@ -32,4 +31,4 @@ release: - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" - docker push "$CI_REGISTRY_IMAGE:latest" rules: - - if: $CI_COMMIT_TAG + - if: $CI_COMMIT_TAG && $CI_COMMIT_BRANCH == "main" From 1f429216897aefb333214cb057a3582859fe41cf Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 26 May 2026 10:52:26 +0800 Subject: [PATCH 050/117] fix: revert CI branch guard, keep --provenance removal Revert the main-branch-only rule since $CI_COMMIT_BRANCH is unset for tag pipelines. The --provenance=false removal remains as the fix for legacy builder. --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 477fe78..618e6fb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,4 +31,4 @@ release: - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" - docker push "$CI_REGISTRY_IMAGE:latest" rules: - - if: $CI_COMMIT_TAG && $CI_COMMIT_BRANCH == "main" + - if: $CI_COMMIT_TAG From 49f9b59b9936ab8e7232e027d9e7b05ee0edd744 Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 26 May 2026 10:58:02 +0800 Subject: [PATCH 051/117] chore: add gradle.properties to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0babb7f..b734735 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,5 @@ gradle-app.setting .project # JDT-specific (Eclipse Java Development Tools) .classpath + +gradle.properties From 26cea1db82d4d210f6d6e801de2bfca90bc8038e Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 26 May 2026 10:58:02 +0800 Subject: [PATCH 052/117] chore: add gradle.properties to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0babb7f..b734735 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,5 @@ gradle-app.setting .project # JDT-specific (Eclipse Java Development Tools) .classpath + +gradle.properties From 0d70b27653a420ac722d13edf06fab10f9a21abe Mon Sep 17 00:00:00 2001 From: siujamo Date: Tue, 26 May 2026 14:24:56 +0800 Subject: [PATCH 053/117] feat: add OpenAPI definition with title, contact, and licence --- .../config/OpenApiConfiguration.java | 26 +++++++++++++++++++ .../controller/VersionController.java | 2 ++ 2 files changed, 28 insertions(+) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java b/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java new file mode 100644 index 0000000..9f47fe1 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java @@ -0,0 +1,26 @@ +package com.onixbyte.deltaforceguide.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import org.springframework.context.annotation.Configuration; + +@OpenAPIDefinition( + info = @Info( + title = "Delta Force Guide Server", + description = "API for managing Delta Force game firearm builds", + version = "1.3.4", + contact = @Contact( + name = "Zihlu Wang", + email = "zihlu.wang@onixbyte.com" + ), + license = @License( + name = "MIT", + url = "https://git.onixbyte.cn/onixbyte/delta-force-guide-server/-/raw/main/LICENCE" + ) + ) +) +@Configuration +public class OpenApiConfiguration { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java index a73b43c..67a08ef 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java @@ -2,10 +2,12 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.service.AppService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "版本信息") @RestController @RequestMapping("/versions") public class VersionController { From 66b37ec20d7ced29834453ea52978a66f73b8b3e Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 13:51:24 +0800 Subject: [PATCH 054/117] fix: add equals and hashCode to Accessory and Tuning entities --- .../domain/entity/Accessory.java | 19 +++++++++++++++++++ .../deltaforceguide/domain/entity/Tuning.java | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java index 8c4b6b9..b76b622 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java @@ -2,6 +2,7 @@ package com.onixbyte.deltaforceguide.domain.entity; import java.util.ArrayList; import java.util.List; +import java.util.Objects; public class Accessory { @@ -45,4 +46,22 @@ public class Accessory { public void removeTuning(Tuning tuning) { this.tunings.remove(tuning); } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Accessory accessory)) { + return false; + } + return Objects.equals(slotName, accessory.slotName) + && Objects.equals(accessoryName, accessory.accessoryName) + && Objects.equals(tunings, accessory.tunings); + } + + @Override + public int hashCode() { + return Objects.hash(slotName, accessoryName, tunings); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java index 4ab9879..83eb1b8 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java @@ -1,5 +1,7 @@ package com.onixbyte.deltaforceguide.domain.entity; +import java.util.Objects; + public class Tuning { private String tuningName; @@ -23,4 +25,21 @@ public class Tuning { public void setTuningValue(Double tuningValue) { this.tuningValue = tuningValue; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Tuning tuning)) { + return false; + } + return Objects.equals(tuningName, tuning.tuningName) + && Objects.equals(tuningValue, tuning.tuningValue); + } + + @Override + public int hashCode() { + return Objects.hash(tuningName, tuningValue); + } } From f866d93fb47bb3a8a68affea532b0231f1e28dc9 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 13:54:30 +0800 Subject: [PATCH 055/117] feat: add gitlab webhook verification --- .../deltaforceguide/config/AppConfig.java | 21 +++- .../deltaforceguide/config/FilterConfig.java | 21 ++++ .../deltaforceguide/config/WebhookConfig.java | 10 ++ .../deltaforceguide/filter/WebhookFilter.java | 22 ++++ .../interceptor/GitLabWebhookInterceptor.java | 109 ++++++++++++++++++ .../manager/WebhookManager.java | 21 ++++ .../properties/GitLabWebhookProperties.java | 6 + .../properties/WebhookProperties.java | 9 ++ .../service/WebhookService.java | 14 +++ .../wrapper/RepeatedlyReadRequestWrapper.java | 56 +++++++++ 10 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index 32ec283..8d7eb6c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -1,10 +1,29 @@ package com.onixbyte.deltaforceguide.config; +import com.onixbyte.deltaforceguide.interceptor.GitLabWebhookInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableConfigurationProperties(AppProperties.class) -public class AppConfig { +public class AppConfig implements WebMvcConfigurer { + + private final GitLabWebhookInterceptor gitLabWebhookInterceptor; + + @Autowired + public AppConfig( + GitLabWebhookInterceptor gitLabWebhookInterceptor + ) { + this.gitLabWebhookInterceptor = gitLabWebhookInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(gitLabWebhookInterceptor) + .addPathPatterns("/webhook/gitlab"); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java new file mode 100644 index 0000000..a80bcfc --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.filter.WebhookFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FilterConfig { + + @Bean + public FilterRegistrationBean loggingFilter(WebhookFilter webhookFilter) { + var registrationBean = new FilterRegistrationBean(); + + registrationBean.setFilter(webhookFilter); + registrationBean.addUrlPatterns("/webhook/*"); + registrationBean.setOrder(1); + + return registrationBean; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java new file mode 100644 index 0000000..1cb1a6c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java @@ -0,0 +1,10 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(WebhookProperties.class) +public class WebhookConfig { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java new file mode 100644 index 0000000..ddfd071 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java @@ -0,0 +1,22 @@ +package com.onixbyte.deltaforceguide.filter; + +import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class WebhookFilter implements Filter { + @Override + public void doFilter( + ServletRequest request, + ServletResponse response, + FilterChain chain + ) throws IOException, ServletException { + var httpRequest = (HttpServletRequest) request; + var wrappedRequest = new RepeatedlyReadRequestWrapper(httpRequest); + chain.doFilter(wrappedRequest, response); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java new file mode 100644 index 0000000..34eb9fd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java @@ -0,0 +1,109 @@ +package com.onixbyte.deltaforceguide.interceptor; + +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +@Component +public class GitLabWebhookInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(GitLabWebhookInterceptor.class); + private static final String TOKEN_PREFIX = "whsec_"; + + private final WebhookProperties webhookProperties; + + public GitLabWebhookInterceptor(WebhookProperties webhookProperties) { + this.webhookProperties = webhookProperties; + } + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (!(request instanceof RepeatedlyReadRequestWrapper req)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Request body is not readable"); + } + + var webhookId = request.getHeader("webhook-id"); + var webhookTimestamp = request.getHeader("webhook-timestamp"); + var webhookSignature = request.getHeader("webhook-signature"); + + if (webhookId == null || webhookTimestamp == null || webhookSignature == null) { + log.warn("Missing webhook headers from ip={}", request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, + "Missing webhook verification headers"); + } + + var signingToken = webhookProperties.gitlab().signingToken(); + if (signingToken == null || signingToken.isBlank()) { + log.debug("No GitLab signing token configured, skipping signature verification"); + return true; + } + + var body = req.getBodyString(); + var signedContent = "%s.%s.%s".formatted(webhookId, webhookTimestamp, body); + + try { + var decodedKey = decodeSigningToken(signingToken); + var computedDigest = computeHmacSha256(decodedKey, signedContent); + var computedSignature = "v1,%s".formatted(computedDigest); + + var signatures = webhookSignature.split(" "); + var matched = false; + for (var sig : signatures) { + if (MessageDigest.isEqual(computedSignature.getBytes(StandardCharsets.UTF_8), + sig.trim().getBytes(StandardCharsets.UTF_8))) { + matched = true; + break; + } + } + + if (!matched) { + log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature"); + } + } catch (BizException e) { + throw e; + } catch (Exception e) { + log.error("Failed to verify webhook signature", e); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to verify webhook signature"); + } + + return true; + } + + private byte[] decodeSigningToken(String token) { + if (!token.startsWith(TOKEN_PREFIX)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Signing token must start with " + TOKEN_PREFIX); + } + var encoded = token.substring(TOKEN_PREFIX.length()); + return Base64.getDecoder().decode(encoded); + } + + private String computeHmacSha256(byte[] key, String data) throws Exception { + var mac = Mac.getInstance("HmacSHA256"); + var secretKey = new SecretKeySpec(key, "HmacSHA256"); + mac.init(secretKey); + var hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java new file mode 100644 index 0000000..a4512ba --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.properties.GitLabWebhookProperties; +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class WebhookManager { + + private final WebhookProperties webhookProperties; + + @Autowired + public WebhookManager(WebhookProperties webhookProperties) { + this.webhookProperties = webhookProperties; + } + + public GitLabWebhookProperties getGitLabWebhookProperties() { + return webhookProperties.gitlab(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java new file mode 100644 index 0000000..318830e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.properties; + +public record GitLabWebhookProperties( + String signingToken +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java new file mode 100644 index 0000000..bcd439c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.webhook") +public record WebhookProperties( + GitLabWebhookProperties gitlab +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java new file mode 100644 index 0000000..4a1f50d --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -0,0 +1,14 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.manager.WebhookManager; +import org.springframework.stereotype.Service; + +@Service +public class WebhookService { + + private final WebhookManager webhookManager; + + public WebhookService(WebhookManager webhookManager) { + this.webhookManager = webhookManager; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java b/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java new file mode 100644 index 0000000..ee31c22 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java @@ -0,0 +1,56 @@ +package com.onixbyte.deltaforceguide.wrapper; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.springframework.util.StreamUtils; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +public class RepeatedlyReadRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public RepeatedlyReadRequestWrapper(HttpServletRequest request) throws IOException { + super(request); + this.body = StreamUtils.copyToByteArray(request.getInputStream()); + } + + @Override + public ServletInputStream getInputStream() { + final var byteArrayInputStream = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + @Override + public int read() throws IOException { + return byteArrayInputStream.read(); + } + }; + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8)); + } + + public String getBodyString() { + return new String(body, StandardCharsets.UTF_8); + } +} From d44f5f74fee421dd8917e77d19b56c52f2ab328b Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 13:55:22 +0800 Subject: [PATCH 056/117] chore: ignore frp client config --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b734735..e026c93 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,6 @@ gradle-app.setting .classpath gradle.properties + +# frp config +frpc.toml From 9bc70d5370ad5a47b8ddd03ebd69be427fc116be Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 15:17:36 +0800 Subject: [PATCH 057/117] feat: add web traffic logger --- .../deltaforceguide/config/AppConfig.java | 18 +++++++- .../interceptor/TrafficInterceptor.java | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index 32ec283..a29d2b5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -1,10 +1,26 @@ package com.onixbyte.deltaforceguide.config; +import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableConfigurationProperties(AppProperties.class) -public class AppConfig { +public class AppConfig implements WebMvcConfigurer { + + private final TrafficInterceptor trafficInterceptor; + + @Autowired + public AppConfig(TrafficInterceptor trafficInterceptor) { + this.trafficInterceptor = trafficInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(trafficInterceptor); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java new file mode 100644 index 0000000..ab3b657 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java @@ -0,0 +1,46 @@ +package com.onixbyte.deltaforceguide.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class TrafficInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(TrafficInterceptor.class); + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + var ip = resolveClientIp(request); + var method = request.getMethod(); + var uri = request.getRequestURI(); + var query = request.getQueryString(); + var contentType = request.getContentType(); + var contentLength = request.getContentLength(); + var userAgent = request.getHeader("User-Agent"); + + log.info("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}", + method, uri, query, ip, contentType, contentLength, userAgent); + return true; + } + + private String resolveClientIp(HttpServletRequest request) { + var xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isBlank()) { + return xForwardedFor.split(",")[0].trim(); + } + var xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isBlank()) { + return xRealIp.trim(); + } + return request.getRemoteAddr(); + } +} From 8d24b6082da8012ebe1589ad9b2789338a5678d8 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 15:18:25 +0800 Subject: [PATCH 058/117] feat: add gitlab webhook http entrypoint --- .../controller/WebhookController.java | 20 +++++++++ .../domain/dto/GitLabWebhookRequest.java | 45 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java new file mode 100644 index 0000000..4ca9a66 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java @@ -0,0 +1,20 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.GitLabWebhookRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/webhook") +public class WebhookController { + + private static final Logger log = LoggerFactory.getLogger(WebhookController.class); + + @PostMapping("/gitlab") + public void gitlabWebhook( + @RequestBody GitLabWebhookRequest request + ) { + log.info("request={}", request); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java new file mode 100644 index 0000000..b48ec75 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java @@ -0,0 +1,45 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import java.time.OffsetDateTime; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitLabWebhookRequest( + String objectKind, + String eventType, + GitLabWebhookObjectAttributes objectAttributes +) { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record GitLabWebhookLabel( + Long id, + String title, + @JsonProperty("color") + String colour, + Long projectId, + String createdAt, + String updatedAt, + Boolean template, + String description, + String type, + Long groupId + ) {} + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record GitLabWebhookObjectAttributes( + Long id, + String title, + String description, + List labels + ) {} + + +} From 72ec8758027072cfa176a853a0bc82aa2e9bfc08 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 15:22:36 +0800 Subject: [PATCH 059/117] docs: add Javadoc for GitLabWebhookInterceptor --- .../interceptor/GitLabWebhookInterceptor.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java index 34eb9fd..b200963 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java @@ -18,6 +18,14 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Base64; +/** + * Verifies GitLab webhook requests by validating the {@code webhook-id}, + * {@code webhook-timestamp}, and {@code webhook-signature} headers against + * a configured signing token using HMAC-SHA256. + * + *

Supports GitLab's v1 signature scheme. Verification is skipped when no + * signing token is configured. + */ @Component public class GitLabWebhookInterceptor implements HandlerInterceptor { @@ -26,10 +34,28 @@ public class GitLabWebhookInterceptor implements HandlerInterceptor { private final WebhookProperties webhookProperties; + /** + * Creates a new interceptor with the given webhook configuration. + * + * @param webhookProperties the webhook configuration properties + */ public GitLabWebhookInterceptor(WebhookProperties webhookProperties) { this.webhookProperties = webhookProperties; } + /** + * Validates the GitLab webhook signature headers on the incoming request. + * Reads {@code webhook-id}, {@code webhook-timestamp}, and + * {@code webhook-signature} headers and verifies the signature against the + * configured signing token. If no token is configured, verification is skipped. + * + * @param request the incoming HTTP request (must be a {@link RepeatedlyReadRequestWrapper}) + * @param response the HTTP response + * @param handler the chosen handler to execute + * @return {@code true} if the request is authentic + * @throws BizException with {@code 401} if headers are missing or the signature is invalid, + * with {@code 500} if verification fails unexpectedly + */ @Override public boolean preHandle( @NonNull HttpServletRequest request, @@ -90,6 +116,14 @@ public class GitLabWebhookInterceptor implements HandlerInterceptor { return true; } + /** + * Decodes a GitLab-format signing token by stripping the {@code whsec_} prefix + * and Base64-decoding the remainder. + * + * @param token the prefixed signing token + * @return the raw key bytes + * @throws BizException if the token does not start with {@code whsec_} + */ private byte[] decodeSigningToken(String token) { if (!token.startsWith(TOKEN_PREFIX)) { throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, @@ -99,6 +133,14 @@ public class GitLabWebhookInterceptor implements HandlerInterceptor { return Base64.getDecoder().decode(encoded); } + /** + * Computes the Base64-encoded HMAC-SHA256 digest for the given data. + * + * @param key the secret key bytes + * @param data the content to sign + * @return Base64-encoded HMAC-SHA256 digest + * @throws Exception if the HMAC algorithm is unavailable + */ private String computeHmacSha256(byte[] key, String data) throws Exception { var mac = Mac.getInstance("HmacSHA256"); var secretKey = new SecretKeySpec(key, "HmacSHA256"); From 4810ef2b1f54cac8bce73b1d6444a29bc02fd942 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 15:24:42 +0800 Subject: [PATCH 060/117] refactor: migrate properties accessing to access via manager --- .../interceptor/GitLabWebhookInterceptor.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java index b200963..68030fc 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java @@ -1,7 +1,7 @@ package com.onixbyte.deltaforceguide.interceptor; import com.onixbyte.deltaforceguide.exeption.BizException; -import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import com.onixbyte.deltaforceguide.manager.WebhookManager; import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -32,15 +32,15 @@ public class GitLabWebhookInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(GitLabWebhookInterceptor.class); private static final String TOKEN_PREFIX = "whsec_"; - private final WebhookProperties webhookProperties; + private final WebhookManager webhookManager; /** * Creates a new interceptor with the given webhook configuration. * - * @param webhookProperties the webhook configuration properties + * @param webhookManager the webhook manager */ - public GitLabWebhookInterceptor(WebhookProperties webhookProperties) { - this.webhookProperties = webhookProperties; + public GitLabWebhookInterceptor(WebhookManager webhookManager) { + this.webhookManager = webhookManager; } /** @@ -77,7 +77,7 @@ public class GitLabWebhookInterceptor implements HandlerInterceptor { "Missing webhook verification headers"); } - var signingToken = webhookProperties.gitlab().signingToken(); + var signingToken = webhookManager.getGitLabWebhookProperties().signingToken(); if (signingToken == null || signingToken.isBlank()) { log.debug("No GitLab signing token configured, skipping signature verification"); return true; From 243283b78878e01d61403c52037c28db4fc2a610 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 15:28:53 +0800 Subject: [PATCH 061/117] docs: reformatted javadocs --- .../deltaforceguide/interceptor/GitLabWebhookInterceptor.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java index 68030fc..f933928 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java @@ -45,6 +45,7 @@ public class GitLabWebhookInterceptor implements HandlerInterceptor { /** * Validates the GitLab webhook signature headers on the incoming request. + *

* Reads {@code webhook-id}, {@code webhook-timestamp}, and * {@code webhook-signature} headers and verifies the signature against the * configured signing token. If no token is configured, verification is skipped. From 70323434873fdeca864bce1c75b8a657f5f64c79 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 28 May 2026 15:35:22 +0800 Subject: [PATCH 062/117] feat: update URI to match standard in GitLab issues --- .../onixbyte/deltaforceguide/controller/WebhookController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java index 4ca9a66..b882977 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java @@ -6,7 +6,7 @@ import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/webhook") +@RequestMapping("/webhooks") public class WebhookController { private static final Logger log = LoggerFactory.getLogger(WebhookController.class); From eb22b3c4bbbb97f676cd590020a892c894fa1a6b Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 29 May 2026 00:42:27 +0800 Subject: [PATCH 063/117] docs: add Javadocs --- .../DeltaForceGuideApplication.java | 5 ++ .../deltaforceguide/client/TokenClient.java | 5 ++ .../deltaforceguide/config/CorsConfig.java | 11 +++ .../deltaforceguide/config/JacksonConfig.java | 5 ++ .../deltaforceguide/config/MyBatisConfig.java | 5 ++ .../config/SecurityConfig.java | 20 +++++ .../config/SpringDataConfig.java | 5 ++ .../controller/AuthController.java | 5 ++ .../controller/DailyPasswordController.java | 5 ++ .../controller/FirearmController.java | 5 ++ .../controller/GlobalExceptionHandler.java | 5 ++ .../controller/ModificationController.java | 5 ++ .../controller/TagController.java | 5 ++ .../converter/FirearmTypeConverter.java | 5 ++ .../domain/dto/AccessoryRequest.java | 5 ++ .../domain/dto/AccessoryResponse.java | 5 ++ .../domain/dto/DailyPassword.java | 5 ++ .../domain/dto/DailyPasswordData.java | 5 ++ .../domain/dto/DailyPasswordMetadata.java | 5 ++ .../domain/dto/DailyPasswordResponse.java | 5 ++ .../domain/dto/ErrorResponse.java | 5 ++ .../domain/dto/FirearmRequest.java | 5 ++ .../domain/dto/FirearmResponse.java | 5 ++ .../domain/dto/LoginRequest.java | 5 ++ .../dto/ModificationBatchCreateRequest.java | 5 ++ .../dto/ModificationBatchDeleteRequest.java | 5 ++ .../domain/dto/ModificationRequest.java | 5 ++ .../domain/dto/ModificationResponse.java | 5 ++ .../domain/dto/PageResponse.java | 5 ++ .../domain/dto/TuningRequest.java | 5 ++ .../domain/dto/TuningResponse.java | 5 ++ .../domain/dto/UserResponse.java | 5 ++ .../domain/entity/Accessory.java | 5 ++ .../domain/entity/Firearm.java | 5 ++ .../domain/entity/Modification.java | 5 ++ .../deltaforceguide/domain/entity/Tuning.java | 5 ++ .../deltaforceguide/domain/entity/User.java | 5 ++ .../domain/entity/UserCredential.java | 7 +- .../domain/entity/UserCredentialId.java | 5 ++ .../enumeration/FirearmType.java | 13 ++++ .../exeption/BizException.java | 5 ++ .../filter/TokenAuthenticationFilter.java | 13 +++- .../manager/CookieManager.java | 5 ++ .../manager/DailyPasswordManager.java | 9 +++ .../manager/UserCredentialManager.java | 41 ++++++++-- .../deltaforceguide/manager/UserManager.java | 43 +++++++++-- .../properties/CookieProperties.java | 10 +++ .../properties/CorsProperties.java | 12 +++ .../properties/TokenProperties.java | 8 ++ .../repository/FirearmRepository.java | 5 ++ .../repository/ModificationRepository.java | 20 +++++ .../repository/UserCredentialRepository.java | 29 +++++++ .../repository/UserRepository.java | 11 +++ .../security/annotation/RequiresAuth.java | 5 ++ .../UsernamePasswordAuthentication.java | 5 ++ ...sernamePasswordAuthenticationProvider.java | 17 ++++ .../deltaforceguide/service/AuthService.java | 18 ++++- .../service/CookieService.java | 20 +++++ .../service/DailyPasswordService.java | 9 +++ .../service/FirearmService.java | 41 ++++++++-- .../service/ModificationService.java | 63 ++++++++++++--- .../deltaforceguide/service/UserService.java | 77 ++++++++++++++++--- .../deltaforceguide/shared/CookieName.java | 5 ++ .../shared/CredentialProvider.java | 5 ++ .../shared/JacksonModules.java | 6 ++ .../shared/JacksonRedisSerialiser.java | 6 ++ .../deltaforceguide/utils/DateTimeUtil.java | 11 +++ 67 files changed, 689 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java index 4d69512..a23fb88 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +/** + * Entry point for the Delta Force Guide Server application. + * + * @author zihluwang + */ @SpringBootApplication public class DeltaForceGuideApplication { diff --git a/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java index d4b745c..a7ad3f1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java +++ b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java @@ -12,6 +12,11 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +/** + * Client for generating and verifying JSON Web Tokens using the Auth0 java-jwt library. + * + * @author zihluwang + */ @Component public class TokenClient { diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java index 291695b..1e13243 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java @@ -12,10 +12,21 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; import java.util.stream.Stream; +/** + * Configuration for CORS (Cross-Origin Resource Sharing) policies. + * + * @author zihluwang + */ @Configuration @EnableConfigurationProperties({CorsProperties.class}) public class CorsConfig { + /** + * Creates the CORS configuration source with properties from configuration. + * + * @param properties the CORS configuration properties + * @return the CORS configuration source + */ @Bean public CorsConfigurationSource corsConfigurationSource( CorsProperties properties diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java index a64a1ea..895bcd1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java @@ -6,6 +6,11 @@ import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilde import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Configuration for Jackson JSON serialisation and deserialisation settings. + * + * @author zihluwang + */ @Configuration public class JacksonConfig { diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java index 61acbfc..0e2ae74 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Configuration; +/** + * Configuration for MyBatis SQL mapping framework integration. + * + * @author zihluwang + */ @Configuration @MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"}) public class MyBatisConfig { diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index 8e8cc50..b32a691 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -23,12 +23,23 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.web.cors.CorsConfigurationSource; +/** + * Spring Security configuration defining authentication, authorisation, and filter chains. + * + * @author zihluwang + */ @Configuration @EnableWebSecurity @EnableMethodSecurity @EnableConfigurationProperties({TokenProperties.class, CookieProperties.class}) public class SecurityConfig { + /** + * Configures the HTTP security filter chain including endpoint authorisation and JWT filter. + * + * @param http the HTTP security builder + * @return the configured security filter chain + */ @Bean public SecurityFilterChain securityFilterChain( HttpSecurity httpSecurity, @@ -48,11 +59,20 @@ public class SecurityConfig { .build(); } + /** + * Provides the BCrypt password encoder for credential hashing. + * @return the BCrypt password encoder + */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + /** + * Provides the authentication manager for the security configuration. + * + * @return the authentication manager + */ @Bean public AuthenticationManager authenticationManager( UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java index 44e3a4c..1dfe3ff 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +/** + * Configuration for Spring Data JPA auditing and repository settings. + * + * @author zihluwang + */ @Configuration @EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"}) public class SpringDataConfig { diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index 86229aa..0a42067 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -19,6 +19,11 @@ import org.springframework.web.bind.annotation.RestController; import java.time.Duration; +/** + * REST controller for user authentication endpoints (login, logout). + * + * @author zihluwang + */ @Tag(name = "用户鉴权", description = "处理用户登录与退出功能") @RestController @RequestMapping("/auth") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java index f817f66..527e730 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java @@ -8,6 +8,11 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +/** + * REST controller for retrieving daily-generated passwords. + * + * @author zihluwang + */ @Tag(name = "每日密码", description = "获取每日密码信息") @RestController @RequestMapping("/daily-passwords") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index 7d95b69..4843c2d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -15,6 +15,11 @@ import org.springframework.data.domain.Sort; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +/** + * REST controller for firearm CRUD operations. + * + * @author zihluwang + */ @Tag(name = "武器管理", description = "与武器有关的操作") @RestController @RequestMapping("/firearms") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java index c0b19f1..4edd154 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java @@ -6,6 +6,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + * Global exception handler that translates exceptions into standard error responses. + * + * @author zihluwang + */ @RestControllerAdvice public class GlobalExceptionHandler { diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index b7c7a8d..7eaf94c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -27,6 +27,11 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +/** + * REST controller for modification CRUD operations, including batch creation and deletion. + * + * @author zihluwang + */ @Tag(name = "改装管理", description = "对枪械改装的管理") @RestController @RequestMapping("/modifications") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java index e22097d..b9f4e59 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java @@ -10,6 +10,11 @@ import com.onixbyte.deltaforceguide.service.ModificationService; import java.util.List; +/** + * REST controller for retrieving available modification tags. + * + * @author zihluwang + */ @Tag(name = "标签管理", description = "管理标签信息") @RestController @RequestMapping("/tags") diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java index f6f46f8..8108db3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +/** + * JPA attribute converter that maps {@link FirearmType} enum to/from its integer database representation. + * + * @author zihluwang + */ @Converter public class FirearmTypeConverter implements AttributeConverter { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java index 29fa638..b94b61c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java @@ -6,6 +6,11 @@ import jakarta.validation.constraints.NotBlank; import java.util.ArrayList; import java.util.List; +/** + * Request DTO for creating or updating an accessory attached to a modification. + * + * @author zihluwang + */ public record AccessoryRequest( @NotBlank(message = "插槽名称不能为空") String slotName, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java index 421410d..ade3eac 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory; import java.util.List; +/** + * Response DTO for an accessory attached to a modification. + * + * @author zihluwang + */ public record AccessoryResponse( String slotName, String accessoryName, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java index 2a381ad..edfa9c0 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * DTO representing a single daily-generated password for a map. + * + * @author zihluwang + */ public record DailyPassword( String mapName, String password diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java index c685542..5e3c12f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import java.time.LocalDateTime; import java.util.List; +/** + * DTO containing daily password data including update information and password list. + * + * @author zihluwang + */ public record DailyPasswordData( String updateDate, Integer totalCount, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java index a2fa36b..9af994f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * DTO holding metadata about the daily password source and update tracking. + * + * @author zihluwang + */ public record DailyPasswordMetadata( String version, String author diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java index 7d29a27..d3ce808 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * Response DTO wrapping daily password data with metadata. + * + * @author zihluwang + */ public record DailyPasswordResponse( String status, String message, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java index f1efbfa..11879fc 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * Standard error response body returned on API failures. + * + * @author zihluwang + */ public record ErrorResponse( String message ) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java index 74e1993..02fb855 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java @@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.enumeration.FirearmType; +/** + * Request DTO for creating or updating a firearm. + * + * @author zihluwang + */ public record FirearmRequest( String name, FirearmType type, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java index eb5b1ac..0e9eaba 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.enumeration.FirearmType; +/** + * Response DTO for a firearm record, including associated modifications. + * + * @author zihluwang + */ public record FirearmResponse( Long id, String name, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java index ab820ec..106d533 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +/** + * Login request containing principle (username/email) and credential (password). + * + * @author zihluwang + */ @Schema(description = "登录请求") public record LoginRequest( @NotBlank(message = "登录名称不能为空") diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java index cdb3d93..c0aa59f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java @@ -5,6 +5,11 @@ import jakarta.validation.constraints.NotEmpty; import java.util.List; +/** + * Request DTO for batch creation of modifications. + * + * @author zihluwang + */ public record ModificationBatchCreateRequest( @NotEmpty(message = "批量创建列表不能为空") List<@Valid ModificationRequest> modifications diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java index 7e46400..4243895 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java @@ -5,6 +5,11 @@ import jakarta.validation.constraints.Positive; import java.util.List; +/** + * Request DTO for batch deletion of modifications by ID. + * + * @author zihluwang + */ public record ModificationBatchDeleteRequest( @NotEmpty(message = "批量删除ID列表不能为空") List<@Positive(message = "ID必须为正数") Long> ids diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java index c602dbd..e4f6708 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java @@ -8,6 +8,11 @@ import jakarta.validation.constraints.Positive; import java.util.ArrayList; import java.util.List; +/** + * Request DTO for creating or updating a modification. + * + * @author zihluwang + */ public record ModificationRequest( @NotNull(message = "武器ID不能为空") @Positive(message = "武器ID必须为正数") diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java index c4d4492..aca6ef5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification; import java.util.List; +/** + * Response DTO for a modification record including accessories and tags. + * + * @author zihluwang + */ public record ModificationResponse( Long id, Long firearmId, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java index 0eefb62..20738bd 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java @@ -4,6 +4,11 @@ import org.springframework.data.domain.Page; import java.util.List; +/** + * Generic paginated response wrapper for list endpoints. + * + * @author zihluwang + */ public record PageResponse( List items, int page, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java index 414becd..a180eb3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +/** + * Request DTO for a tuning adjustment on an accessory. + * + * @author zihluwang + */ public record TuningRequest( @NotBlank(message = "调校项名称不能为空") String tuningName, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java index 3f6febf..405ecde 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java @@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.domain.entity.Tuning; +/** + * Response DTO for a tuning adjustment on an accessory. + * + * @author zihluwang + */ public record TuningResponse( String tuningName, Double tuningValue diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java index 75d9188..b20231b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java @@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.domain.entity.User; +/** + * Response DTO for a user account, including associated credentials. + * + * @author zihluwang + */ public record UserResponse( Long id, String username, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java index 8c4b6b9..fc9802d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.entity; import java.util.ArrayList; import java.util.List; +/** + * Entity representing an accessory attached to a modification, stored as JSONB. + * + * @author zihluwang + */ public class Accessory { private String slotName; diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java index 65942d3..c71cee7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java @@ -15,6 +15,11 @@ import jakarta.persistence.Table; import java.util.ArrayList; import java.util.List; +/** + * Entity representing a firearm in the Delta Force game. + * + * @author zihluwang + */ @Entity @Table(name = "firearm") public class Firearm { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java index ed145b5..a1942f7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java @@ -7,6 +7,11 @@ import org.hibernate.annotations.Type; import java.util.ArrayList; import java.util.List; +/** + * Entity representing a firearm modification or build configuration. + * + * @author zihluwang + */ @Entity @Table( name = "modification", diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java index 4ab9879..102ea63 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.entity; +/** + * Entity representing a tuning adjustment for an accessory, stored as JSONB within Accessory. + * + * @author zihluwang + */ public class Tuning { private String tuningName; diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java index 67142df..d696b2d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -12,6 +12,11 @@ import jakarta.persistence.Table; import java.util.ArrayList; import java.util.List; +/** + * Entity representing an application user with authentication credentials. + * + * @author zihluwang + */ @Entity @Table(name = "app_user") public class User { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java index b8a09a6..2ecdd37 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java @@ -12,6 +12,11 @@ import jakarta.persistence.MapsId; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +/** + * Entity representing a user credential linked to an authentication provider. + * + * @author zihluwang + */ @Entity @Table(name = "app_user_credential") public class UserCredential { @@ -28,7 +33,7 @@ public class UserCredential { @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user")) private User user; - @Column(name = "credential", nullable = false, length = 255) + @Column(name = "credential", nullable = false) private String credential; public UserCredentialId getId() { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java index 57a819e..1e25f45 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java @@ -5,6 +5,11 @@ import jakarta.persistence.Embeddable; import java.io.Serializable; import java.util.Objects; +/** + * Composite key for the UserCredential entity, combining user ID and provider. + * + * @author zihluwang + */ @Embeddable public class UserCredentialId implements Serializable { diff --git a/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java b/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java index d4965fa..77bdbdb 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java +++ b/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java @@ -1,5 +1,11 @@ package com.onixbyte.deltaforceguide.enumeration; +/** + * Enumeration of firearm types in the Delta Force game. + * Each type is associated with an integer code used for database persistence. + * + * @author zihluwang + */ public enum FirearmType { RIFLE(0), @@ -21,6 +27,13 @@ public enum FirearmType { return code; } + /** + * Resolve a FirearmType from its integer code. + * + * @param code the integer code, may be null + * @return the corresponding FirearmType, or null if the code is null + * @throws IllegalArgumentException if the code does not match any known type + */ public static FirearmType fromCode(Integer code) { if (code == null) { return null; diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java index aad96d2..d1b7ff2 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java @@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.exeption; import org.springframework.http.HttpStatus; +/** + * Custom runtime exception that carries an HTTP status code for API error responses. + * + * @author zihluwang + */ public class BizException extends RuntimeException { /** diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index f4b9220..e2068a6 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -16,7 +16,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -29,6 +28,11 @@ import java.time.Instant; import java.util.Objects; import java.util.Optional; +/** + * Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request. + * + * @author zihluwang + */ @Component public class TokenAuthenticationFilter extends OncePerRequestFilter { @@ -52,6 +56,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { this.handlerExceptionResolver = handlerExceptionResolver; } + /** + * Extracts JWT from httpOnly cookie, validates it, and sets the security context. + * + * @param request the HTTP request + * @param response the HTTP response + * @param filterChain the filter chain + */ @Override protected void doFilterInternal( @NonNull HttpServletRequest request, diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java index 7bec528..f6ef16f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java @@ -6,6 +6,11 @@ import org.springframework.stereotype.Component; import java.time.Duration; +/** + * Manager providing cookie construction operations with configurable properties. + * + * @author zihluwang + */ @Component public class CookieManager { diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java index 3ec28f9..6b6ac59 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java @@ -17,6 +17,11 @@ import java.time.Duration; import java.time.LocalDate; import java.util.Objects; +/** + * Manager for daily password data access and caching coordination. + * + * @author zihluwang + */ @Component public class DailyPasswordManager { @@ -49,6 +54,10 @@ public class DailyPasswordManager { this.redisTemplate = redisTemplate; } + /** + * Retrieves the daily password from cache or generates a new one. + * @return the daily password response + */ public DailyPasswordResponse getDailyPassword() { var key = CACHE_KEY_PREFIX + LocalDate.now(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java index f4aa494..d77c687 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java @@ -3,11 +3,15 @@ package com.onixbyte.deltaforceguide.manager; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.repository.UserCredentialRepository; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +/** + * Manager for user credential persistence and authentication data access. + * + * @author zihluwang + */ @Component public class UserCredentialManager { @@ -17,27 +21,52 @@ public class UserCredentialManager { this.userCredentialRepository = userCredentialRepository; } - @Transactional(readOnly = true) + /** + * Finds all credentials belonging to a specific user. + * + * @param userId the user ID + * @return list of matching credentials + */ public List findAllByUserId(Long userId) { return userCredentialRepository.findAllByUserId(userId); } - @Transactional(readOnly = true) + /** + * Finds a credential for a specific user and provider combination. + * + * @param userId the user ID + * @param provider the authentication provider + * @return the matching credential, if found + */ public Optional findByUserIdAndProvider(Long userId, String provider) { return userCredentialRepository.findByUserIdAndProvider(userId, provider); } - @Transactional + /** + * Persists a new or updated credential. + * + * @param userCredential the credential to save + * @return the saved credential + */ public UserCredential save(UserCredential userCredential) { return userCredentialRepository.save(userCredential); } - @Transactional + /** + * Deletes a credential for a specific user and provider. + * + * @param userId the user ID + * @param provider the authentication provider + */ public void deleteByUserIdAndProvider(Long userId, String provider) { userCredentialRepository.deleteByUserIdAndProvider(userId, provider); } - @Transactional + /** + * Deletes all credentials belonging to a user. + * + * @param userId the user ID + */ public void deleteAllByUserId(Long userId) { userCredentialRepository.deleteAllByUserId(userId); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java index fb8ac56..d63c0f7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java @@ -8,6 +8,11 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +/** + * Manager for user entity persistence and query operations. + * + * @author zihluwang + */ @Component public class UserManager { @@ -17,17 +22,30 @@ public class UserManager { this.userRepository = userRepository; } - @Transactional(readOnly = true) + /** + * Finds a user by their ID. + * + * @param id the user ID + * @return the matching user, if found + */ public Optional findById(Long id) { return userRepository.findById(id); } - @Transactional(readOnly = true) + /** + * Retrieves all registered users. + * @return list of all users + */ public List findAll() { return userRepository.findAll(); } - @Transactional(readOnly = true) + /** + * Finds a user by their username. + * + * @param username the username to search for + * @return the matching user, if found + */ public Optional findByUsername(String username) { return userRepository.findByUsername(username); } @@ -37,16 +55,31 @@ public class UserManager { return userRepository.findByEmail(email); } - @Transactional + /** + * Persists a new or updated user. + * + * @param user the user to save + * @return the saved user + */ public User save(User user) { return userRepository.save(user); } - @Transactional + /** + * Deletes a user by their ID. + * + * @param id the user ID to delete + */ public void deleteById(Long id) { userRepository.deleteById(id); } + /** + * Finds a user by their username or email address. + * + * @param principal the username or email to search for + * @return the matching user, if found + */ public Optional findByUsernameOrEmail(String principal) { return userRepository.findByUsernameOrEmail(principal); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java index 86dab77..0cfde08 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java @@ -6,6 +6,16 @@ import org.springframework.boot.web.server.Cookie; import java.time.Duration; +/** + * Configuration properties for HTTP cookies used in authentication, prefixed with "app.cookie". + * + * @param httpOnly whether the cookie is httpOnly + * @param secure whether the cookie is secure + * @param path the cookie path + * @param maxAge the maximum age of the cookie + * @param sameSite the SameSite policy for the cookie + * @author zihluwang + */ @ConfigurationProperties(prefix = "app.cookie") public record CookieProperties( @DefaultValue("true") Boolean httpOnly, diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java index ff064d5..193640f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java @@ -6,6 +6,18 @@ import org.springframework.http.HttpMethod; import java.time.Duration; +/** + * Configuration properties for CORS settings, prefixed with "app.cors". + * + * @param allowedHeaders headers allowed in CORS requests + * @param allowedMethods HTTP methods allowed in CORS requests + * @param allowedOrigins origins permitted to make cross-origin requests + * @param allowCredentials whether credentials are allowed in CORS requests + * @param allowPrivateNetwork whether private network access is permitted + * @param maxAge how long the CORS preflight response may be cached + * @param exposedHeaders headers exposed to the client in CORS responses + * @author zihluwang + */ @ConfigurationProperties(prefix = "app.cors") public record CorsProperties( @DefaultValue({"Content-Type", "Authorization"}) diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java index 0d7a618..74c693c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java @@ -4,6 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import java.time.Duration; +/** + * Configuration properties for JWT token generation and validation, prefixed with "app.jwt". + * + * @param issuer the JWT issuer claim + * @param secret the signing secret for JWT tokens + * @param validTime the duration for which a token remains valid + * @author zihluwang + */ @ConfigurationProperties(prefix = "app.jwt") public record TokenProperties( String issuer, diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index dcf85f8..e9ad930 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -7,6 +7,11 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +/** + * Spring Data JPA repository for {@link Firearm} entity operations. + * + * @author zihluwang + */ @Repository public interface FirearmRepository extends JpaRepository { diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index d4f891a..3d9fcc0 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -13,6 +13,12 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * Spring Data JPA repository for {@link Modification} entity operations, + * including native JSONB tag filtering for Postgres. + * + * @author zihluwang + */ @Repository public interface ModificationRepository extends JpaRepository { @@ -27,6 +33,14 @@ public interface ModificationRepository extends JpaRepository findById(@NonNull Long id); + /** + * Page query modifications with optional firearm and JSONB tag filtering. + * + * @param firearmId optional firearm ID filter (nullable) + * @param tagsJson optional JSON array of tags to match via Postgres {@code @>} operator (nullable) + * @param pageable pagination information + * @return a page of matching modifications + */ @Query(value = """ SELECT * FROM modification m WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) @@ -40,6 +54,12 @@ public interface ModificationRepository extends JpaRepository pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable); + /** + * Retrieve all distinct tag values from modifications, optionally filtered by firearm. + * + * @param firearmId optional firearm ID filter (nullable) + * @return list of distinct tag strings + */ @Query(value = "SELECT DISTINCT jsonb_array_elements_text(cast(tags as jsonb)) FROM modification WHERE (:firearmId IS NULL OR firearm_id = :firearmId)", nativeQuery = true) List findAllTags(@Param("firearmId") Long firearmId); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java index 94fb8ec..53776e3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java @@ -12,9 +12,20 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * Spring Data JPA repository for {@link UserCredential} entity operations. + * + * @author zihluwang + */ @Repository public interface UserCredentialRepository extends JpaRepository { + /** + * Find all credentials belonging to a given user. + * + * @param userId the user ID + * @return list of matching credentials + */ @EntityGraph(attributePaths = {"user"}) @Query(""" select uc @@ -23,6 +34,13 @@ public interface UserCredentialRepository extends JpaRepository findAllByUserId(@Param("userId") Long userId); + /** + * Find a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider identifier + * @return an optional containing the matching credential, or empty if not found + */ @EntityGraph(attributePaths = {"user"}) @Query(""" select uc @@ -32,6 +50,12 @@ public interface UserCredentialRepository extends JpaRepository findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider); + /** + * Delete a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider identifier + */ @Modifying @Query(""" delete from UserCredential uc @@ -40,6 +64,11 @@ public interface UserCredentialRepository extends JpaRepository { @@ -28,6 +33,12 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); + /** + * Find a user by either username or email. + * + * @param principal the username or email to search for + * @return an optional containing the matching user, or empty if not found + */ @EntityGraph(attributePaths = {"credentials"}) @Query(""" select u diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java index f022be3..9b6b014 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java @@ -7,6 +7,11 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Annotation to mark controller endpoints that require authentication. + * + * @author zihluwang + */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @PreAuthorize("isAuthenticated()") diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java index 692efaf..9bd2ae3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java @@ -8,6 +8,11 @@ import org.springframework.security.core.GrantedAuthority; import java.util.Collection; import java.util.List; +/** + * Custom authentication token for username/password-based login flows. + * + * @author zihluwang + */ public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer { private final String username; private String password; diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java index d5e9a65..01e6baa 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -17,6 +17,11 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +/** + * Authentication provider that validates username/password credentials against stored BCrypt hashes. + * + * @author zihluwang + */ @Component public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider { @@ -36,6 +41,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro this.userCredentialRepository = userCredentialRepository; } + /** + * Validates the username/password credentials against stored BCrypt hashes. + * + * @param authentication the authentication request object + * @return a fully authenticated object including user details + */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) { @@ -75,6 +86,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro return usernamePasswordAuthentication; } + /** + * Checks if this provider supports the given authentication type. + * + * @param authentication the authentication class to check + * @return true if this provider supports the given type + */ @Override public boolean supports(Class authentication) { return UsernamePasswordAuthentication.class.isAssignableFrom(authentication); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index b1bbfc7..da41902 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -10,9 +10,11 @@ import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.stereotype.Service; -import java.util.Objects; -import java.util.Optional; - +/** + * Service handling user authentication, login, and session management. + * + * @author zihluwang + */ @Service public class AuthService { @@ -23,6 +25,16 @@ public class AuthService { this.authenticationManager = authenticationManager; } + /** + * Authenticates a user with the given login credentials. + *

+ * Delegates authentication to Spring Security's {@link AuthenticationManager} and verifies + * that the result is of the expected {@link UsernamePasswordAuthentication} type. + * + * @param request the login credentials containing principle and password + * @return the authenticated {@link User} + * @throws BizException if authentication fails or the result type is unexpected + */ public User login(LoginRequest request) { var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication .unauthenticated(request.principle(), request.credential())); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java index 764b47b..eb93086 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java @@ -6,6 +6,11 @@ import org.springframework.stereotype.Service; import java.time.Duration; +/** + * Service for building HTTP cookies with configurable properties. + * + * @author zihluwang + */ @Service public class CookieService { @@ -15,10 +20,25 @@ public class CookieService { this.cookieManager = cookieManager; } + /** + * Builds a response cookie with the default max age from configuration. + * + * @param cookieName the cookie name + * @param value the cookie value + * @return a configured ResponseCookie + */ public ResponseCookie buildCookie(String cookieName, String value) { return buildCookieInternal(cookieName, value, cookieManager.getMaxAge()); } + /** + * Builds a response cookie with a custom valid duration. + * + * @param cookieName the cookie name + * @param value the cookie value + * @param validDuration the cookie's max age + * @return a configured ResponseCookie + */ public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) { return buildCookieInternal(cookieName, value, validDuration); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java b/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java index 3ad3d4e..fa2118d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse; import com.onixbyte.deltaforceguide.manager.DailyPasswordManager; import org.springframework.stereotype.Service; +/** + * Service for generating and caching daily rotation passwords. + * + * @author zihluwang + */ @Service public class DailyPasswordService { @@ -13,6 +18,10 @@ public class DailyPasswordService { this.dailyPasswordManager = dailyPasswordManager; } + /** + * Retrieves the daily password for the current day. + * @return the daily password response + */ public DailyPasswordResponse getDailyPassword() { return dailyPasswordManager.getDailyPassword(); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java index 3b69c15..5f5098b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java @@ -11,9 +11,13 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +/** + * Service handling firearm business logic including CRUD operations and queries. + * + * @author zihluwang + */ @Service public class FirearmService { @@ -23,7 +27,13 @@ public class FirearmService { this.firearmRepository = firearmRepository; } - @Transactional(readOnly = true) + /** + * Queries firearms with optional type filter and pagination. + * + * @param type optional firearm type filter + * @param pageable pagination parameters + * @return a paginated response of firearm records + */ public PageResponse pageQuery(FirearmType type, Pageable pageable) { Page page = type == null ? firearmRepository.findAll(pageable) @@ -32,13 +42,24 @@ public class FirearmService { return PageResponse.from(page.map(FirearmResponse::from)); } - @Transactional(readOnly = true) + /** + * Finds a firearm by its ID. + * + * @param id the firearm ID + * @return the firearm response + */ public FirearmResponse queryById(Long id) { return firearmRepository.findById(id) .map(FirearmResponse::from) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); } + /** + * Creates a new firearm from the provided request data. + * + * @param request the firearm creation request + * @return the created firearm response + */ public FirearmResponse addFirearm(FirearmRequest request) { var firearm = firearmRepository.save(Firearm.builder() .name(request.name()) @@ -54,7 +75,13 @@ public class FirearmService { return FirearmResponse.from(firearm); } - @Transactional + /** + * Updates an existing firearm identified by ID. + * + * @param id the firearm ID + * @param request the updated firearm data + * @return the updated firearm response + */ public FirearmResponse updateFirearm(Long id, FirearmRequest request) { var firearm = firearmRepository.findById(id) .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); @@ -71,7 +98,11 @@ public class FirearmService { return FirearmResponse.from(firearmRepository.save(firearm)); } - @Transactional + /** + * Deletes a firearm by its ID. + * + * @param id the firearm ID to delete + */ public void deleteFirearm(Long id) { Firearm firearm = firearmRepository.findById(id) .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index 39e5ec0..7a9657e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -17,7 +17,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.util.ArrayList; @@ -27,6 +26,11 @@ import java.util.List; import java.util.Map; import java.util.Set; +/** + * Service handling modification business logic including CRUD, batch operations, and tag filtering. + * + * @author zihluwang + */ @Service public class ModificationService { @@ -44,7 +48,14 @@ public class ModificationService { this.objectMapper = objectMapper; } - @Transactional(readOnly = true) + /** + * Queries modifications with optional firearm and tag filters. + * + * @param firearmId optional firearm ID filter + * @param tags optional tag list filter + * @param pageable pagination parameters + * @return a paginated response of modification records + */ public PageResponse pageQuery(Long firearmId, List tags, Pageable pageable) { String tagsJson = null; if (tags != null && !tags.isEmpty()) { @@ -65,19 +76,34 @@ public class ModificationService { return PageResponse.from(page.map(ModificationResponse::from)); } - @Transactional(readOnly = true) + /** + * Finds a modification by its ID. + * + * @param id the modification ID + * @return the modification response + */ public ModificationResponse queryById(Long id) { return modificationRepository.findById(id) .map(ModificationResponse::from) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); } - @Transactional(readOnly = true) + /** + * Finds all unique tags across modifications, optionally scoped to a firearm. + * + * @param firearmId optional firearm ID to scope the tag search + * @return list of unique tag strings + */ public List findAllTags(Long firearmId) { return modificationRepository.findAllTags(firearmId); } - @Transactional + /** + * Creates a new modification for a given firearm. + * + * @param request the modification creation request + * @return the created modification response + */ public ModificationResponse create(ModificationRequest request) { Firearm firearm = firearmRepository.findById(request.firearmId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId())); @@ -86,7 +112,12 @@ public class ModificationService { return ModificationResponse.from(modificationRepository.save(modification)); } - @Transactional + /** + * Creates multiple modifications in a single batch operation. + * + * @param requests list of modification creation requests + * @return list of created modification responses + */ public List batchCreate(List requests) { Set firearmIds = requests.stream() .map(ModificationRequest::firearmId) @@ -111,7 +142,13 @@ public class ModificationService { .toList(); } - @Transactional + /** + * Updates an existing modification identified by ID. + * + * @param id the modification ID + * @param request the updated modification data + * @return the updated modification response + */ public ModificationResponse update(Long id, ModificationRequest request) { Modification modification = modificationRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); @@ -130,14 +167,22 @@ public class ModificationService { return ModificationResponse.from(modificationRepository.save(modification)); } - @Transactional + /** + * Deletes a modification by its ID. + * + * @param id the modification ID to delete + */ public void delete(Long id) { Modification modification = modificationRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); modificationRepository.delete(modification); } - @Transactional + /** + * Deletes multiple modifications in a single batch operation. + * + * @param ids list of modification IDs to delete + */ public void batchDelete(List ids) { Set uniqueIds = new LinkedHashSet<>(ids); List modifications = modificationRepository.findAllById(uniqueIds); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java index c57ce4f..700e26d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -6,11 +6,15 @@ import com.onixbyte.deltaforceguide.manager.UserCredentialManager; import com.onixbyte.deltaforceguide.manager.UserManager; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.util.List; +/** + * Service for user account management and profile operations. + * + * @author zihluwang + */ @Service public class UserService { @@ -22,29 +26,53 @@ public class UserService { this.userCredentialManager = userCredentialManager; } - @Transactional(readOnly = true) + /** + * Retrieves all registered users. + * + * @return list of all users + */ public List findAll() { return userManager.findAll(); } - @Transactional(readOnly = true) + /** + * Finds a user by their ID. + * + * @param id the user ID + * @return the user + */ public User queryById(Long id) { return userManager.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id)); } - @Transactional(readOnly = true) + /** + * Finds a user by their username. + * + * @param username the username to search for + * @return the user + */ public User queryByUsername(String username) { return userManager.findByUsername(username) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + username)); } - @Transactional + /** + * Creates a new user account. + * + * @param user the user entity to persist + * @return the saved user entity + */ public User create(User user) { return userManager.save(user); } - @Transactional + /** + * Updates an existing user account. + * + * @param user the user entity with updated fields + * @return the saved user entity + */ public User update(User user) { if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId()); @@ -52,13 +80,24 @@ public class UserService { return userManager.save(user); } - @Transactional(readOnly = true) + /** + * Finds all credentials associated with a user. + * + * @param userId the user ID + * @return list of user credentials + */ public List findCredentials(Long userId) { ensureUserExists(userId); return userCredentialManager.findAllByUserId(userId); } - @Transactional(readOnly = true) + /** + * Queries a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider + * @return the matching credential + */ public UserCredential queryCredential(Long userId, String provider) { ensureUserExists(userId); return userCredentialManager.findByUserIdAndProvider(userId, provider) @@ -67,7 +106,14 @@ public class UserService { "User credential not found: userId=" + userId + ", provider=" + provider)); } - @Transactional + /** + * Creates or updates a credential for a user and provider. + * + * @param userId the user ID + * @param provider the authentication provider + * @param credential the credential value + * @return the saved credential + */ public UserCredential upsertCredential(Long userId, String provider, String credential) { User user = ensureUserExists(userId); UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider) @@ -78,13 +124,22 @@ public class UserService { return userCredentialManager.save(userCredential); } - @Transactional + /** + * Deletes a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider + */ public void deleteCredential(Long userId, String provider) { ensureUserExists(userId); userCredentialManager.deleteByUserIdAndProvider(userId, provider); } - @Transactional + /** + * Deletes a user and all associated credentials. + * + * @param id the user ID to delete + */ public void deleteById(Long id) { ensureUserExists(id); userCredentialManager.deleteAllByUserId(id); diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java b/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java index 329852b..26f0112 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.shared; +/** + * Constants for HTTP cookie names used for authentication tokens. + * + * @author zihluwang + */ public class CookieName { public static final String ACCESS_TOKEN = "AccessToken"; diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java index 115f935..9488536 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.shared; +/** + * Constants for supported authentication provider identifiers. + * + * @author zihluwang + */ public class CredentialProvider { public static final String LOCAL = "LOCAL"; diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java index 36332aa..ae47823 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java @@ -13,6 +13,12 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +/** + * Shared Jackson {@link com.fasterxml.jackson.databind.Module} instances for custom date/time + * serialisation and deserialisation across the application. + * + * @author zihluwang + */ public class JacksonModules { public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java index 5a300ba..4e50c38 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java @@ -4,6 +4,12 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +/** + * Singleton serialiser for Redis cache operations using + * {@link GenericJackson2JsonRedisSerializer} with JavaTime module support. + * + * @author zihluwang + */ public class JacksonRedisSerialiser { public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java index f1360c8..1d576a5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java +++ b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java @@ -4,8 +4,19 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; +/** + * Utility class for date and time operations using system-default time zone. + * + * @author zihluwang + */ public class DateTimeUtil { + /** + * Convert a {@link LocalDateTime} to an {@link Instant} using the system-default time zone. + * + * @param ldt the local date-time to convert + * @return the corresponding instant + */ public static Instant asInstant(LocalDateTime ldt) { return ldt.atZone(ZoneId.systemDefault()) .toInstant(); From ce330bca871011d1d22307a9130cbdcd7926b465 Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 29 May 2026 15:10:48 +0800 Subject: [PATCH 064/117] feat: create GitHub Webhook request object --- .../deltaforceguide/config/FilterConfig.java | 2 +- .../dto/GitHubIssueLabeledWebhookRequest.java | 7 +++++++ .../domain/dto/GitHubWebhookIssue.java | 13 +++++++++++++ .../domain/dto/GitHubWebhookLabel.java | 6 ++++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java index a80bcfc..40442ab 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java @@ -13,7 +13,7 @@ public class FilterConfig { var registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(webhookFilter); - registrationBean.addUrlPatterns("/webhook/*"); + registrationBean.addUrlPatterns("/webhooks/*"); registrationBean.setOrder(1); return registrationBean; diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java new file mode 100644 index 0000000..b7384ef --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java @@ -0,0 +1,7 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record GitHubIssueLabeledWebhookRequest( + String action, + GitHubWebhookIssue issue +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java new file mode 100644 index 0000000..abc3655 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import java.util.List; + +public record GitHubWebhookIssue( + String url, + Long id, + String title, + String body, + List labels +) { + +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java new file mode 100644 index 0000000..fb3407a --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record GitHubWebhookLabel( + String name +) { +} From f9c210c8b3429110201874579aaca43914b5458f Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 13:58:03 +0800 Subject: [PATCH 065/117] chore: bump onixbyte toolbox to v3.4.0 --- gradle/libs.versions.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89621f5..811ee52 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,10 +4,9 @@ javaJwtVersion = "4.5.1" postgresDriverVersion = "42.7.9" h2Version = "2.2.224" springBootVersion = "3.5.13" -springSecurityVersion = "6.5.2" reactorVersion = "3.7.8" junitPlatformVersion = "1.12.2" -onixbyteVersion = "3.3.0" +onixbyteVersion = "3.4.0" onixbyteCaptcha = "1.1.0" onixbyteRegions = "2025.12.0" awsSdkVersion = "2.25.48" @@ -42,12 +41,10 @@ mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spri spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" } hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" } postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" } -mysql-driver = { group = "com.mysql", name = "mysql-connector-j" } h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" } spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" } flyway-core = { group = "org.flywaydb", name = "flyway-core" } flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" } -flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" } # Spring Boot Core & Web spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } From 44271eeec4bae1460228afdf13c62f47f8312e70 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 15:27:35 +0800 Subject: [PATCH 066/117] feat: add GitHub webhook DTOs with snake_case mapping and header constants Replace GitHubIssueLabeledWebhookRequest with GitHubIssueRequest. Add number and repository fields for dedup key construction. Add Jackson @JsonNaming/@JsonIgnoreProperties for GitHub payload deserialisation. Add GitHubWebhookHeader constants for webhook header names. --- .../dto/GitHubIssueLabeledWebhookRequest.java | 7 --- .../domain/dto/GitHubIssueRequest.java | 14 +++++ .../domain/dto/GitHubWebhookIssue.java | 7 +++ .../domain/dto/GitHubWebhookLabel.java | 6 ++ .../domain/dto/GitHubWebhookRepository.java | 12 ++++ .../shared/GitHubWebhookHeader.java | 56 +++++++++++++++++++ 6 files changed, 95 insertions(+), 7 deletions(-) delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookRepository.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/shared/GitHubWebhookHeader.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java deleted file mode 100644 index b7384ef..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueLabeledWebhookRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.onixbyte.deltaforceguide.domain.dto; - -public record GitHubIssueLabeledWebhookRequest( - String action, - GitHubWebhookIssue issue -) { -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java new file mode 100644 index 0000000..449591b --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java @@ -0,0 +1,14 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubIssueRequest( + String action, + GitHubWebhookIssue issue, + GitHubWebhookRepository repository +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java index abc3655..91263cc 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java @@ -1,10 +1,17 @@ package com.onixbyte.deltaforceguide.domain.dto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + import java.util.List; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) public record GitHubWebhookIssue( String url, Long id, + Long number, String title, String body, List labels diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java index fb3407a..33954ab 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java @@ -1,5 +1,11 @@ package com.onixbyte.deltaforceguide.domain.dto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) public record GitHubWebhookLabel( String name ) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookRepository.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookRepository.java new file mode 100644 index 0000000..e3858d6 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookRepository.java @@ -0,0 +1,12 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubWebhookRepository( + String fullName +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/GitHubWebhookHeader.java b/src/main/java/com/onixbyte/deltaforceguide/shared/GitHubWebhookHeader.java new file mode 100644 index 0000000..05b9d58 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/GitHubWebhookHeader.java @@ -0,0 +1,56 @@ +package com.onixbyte.deltaforceguide.shared; + +/** + * This class lists the header names that GitHub sends in webhook requests. + * + * @author siujamo + */ +public class GitHubWebhookHeader { + + /** + * The unique identifier of the webhook. + */ + public static final String HOOK_ID = "X-GitHub-Hook-ID"; + + /** + * The name of the event that triggered the delivery. + */ + public static final String EVENT = "X-GitHub-Event"; + + /** + * A globally unique identifier (GUID) to identify the event. + */ + public static final String DELIVERY = "X-GitHub-Delivery"; + + /** + * This header is sent if the webhook is configured with a {@code secret}. This is the HMAC hex + * digest of the request body, and is generated using the SHA-1 hash function and the secret as + * the HMAC {@code key}. {@code X-Hub-Signature} is provided for compatibility with + * existing integrations. We recommend that you use the more secure + * {@code X-Hub-Signature-256} instead. + */ + public static final String SIGNATURE = "X-Hub-Signature"; + + /** + * This header is sent if the webhook is configured with a {@code secret}. This is the HMAC hex + * digest of the request body, and is generated using the SHA-256 hash function and the + * {@code secret} as the HMAC key. For more information, see Validating webhook deliveries. + */ + public static final String SIGNATURE_256 = "X-Hub-Signature-256"; + + /** + * This header will always have the prefix {@code GitHub-Hookshot/}. + */ + public static final String USER_AGENT = "User-Agent"; + + /** + * The type of resource where the webhook was created. + */ + public static final String INSTALLATION_TARGET_TYPE = "X-GitHub-Hook-Installation-Target-Type"; + + /** + * The unique identifier of the resource where the webhook was created. + */ + public static final String INSTALLATION_TARGET_ID = "X-GitHub-Hook-Installation-Target-ID"; +} From 12469f1b27d99e2a71cc49deb3eda8c96101fd41 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 15:29:19 +0800 Subject: [PATCH 067/117] feat: replace GitLab webhook properties with GitHub webhook config Replace GitLabWebhookProperties with GitHubWebhookProperties (secret, allowed-users). Update WebhookProperties and WebhookManager to use GitHub-only configuration. Add app.webhook.github defaults to application.yaml. --- .../onixbyte/deltaforceguide/manager/WebhookManager.java | 6 +++--- .../properties/GitHubWebhookProperties.java | 9 +++++++++ .../properties/GitLabWebhookProperties.java | 6 ------ .../deltaforceguide/properties/WebhookProperties.java | 2 +- src/main/resources/application.yaml | 6 ++++++ 5 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java index a4512ba..1e63f36 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java @@ -1,6 +1,6 @@ package com.onixbyte.deltaforceguide.manager; -import com.onixbyte.deltaforceguide.properties.GitLabWebhookProperties; +import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties; import com.onixbyte.deltaforceguide.properties.WebhookProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -15,7 +15,7 @@ public class WebhookManager { this.webhookProperties = webhookProperties; } - public GitLabWebhookProperties getGitLabWebhookProperties() { - return webhookProperties.gitlab(); + public GitHubWebhookProperties github() { + return webhookProperties.github(); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java new file mode 100644 index 0000000..bf91c6a --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.properties; + +import java.util.List; + +public record GitHubWebhookProperties( + String secret, + List allowedUsers +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java deleted file mode 100644 index 318830e..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/GitLabWebhookProperties.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.onixbyte.deltaforceguide.properties; - -public record GitLabWebhookProperties( - String signingToken -) { -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java index bcd439c..d420105 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java @@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "app.webhook") public record WebhookProperties( - GitLabWebhookProperties gitlab + GitHubWebhookProperties github ) { } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 66611ee..74de069 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -39,6 +39,12 @@ mybatis: type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler mapper-locations: classpath:/mapper/*.xml +app: + webhook: + github: + secret: ${GITHUB_WEBHOOK_SECRET:} + allowed-users: [] + logging: level: org.hibernate: From 8c8ca58b745061d00996f32b2feeeec2d394ac4c Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 15:29:32 +0800 Subject: [PATCH 068/117] feat: implement GitHub webhook HMAC-SHA256 signature verification Verify X-Hub-Signature-256 header using CryptoUtil.hmacSha256 from onixbyte crypto-toolbox. Signature check is skipped when no secret is configured. Uses MessageDigest.isEqual for constant-time comparison. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../interceptor/GitHubWebhookInterceptor.java | 85 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java diff --git a/build.gradle.kts b/build.gradle.kts index 0ce2b78..878912f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.onixbyte.identityGenerator) implementation(libs.onixbyte.captcha) implementation(libs.onixbyte.regions) + implementation(libs.onixbyte.cryptoToolbox) implementation(libs.jwt.core) implementation(libs.spring.boot.configurationProcessor) implementation(libs.spring.boot.actuator) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 811ee52..d9650f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-dat onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" } onixbyte-tuple = { group = "com.onixbyte", name = "tuple" } onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" } +onixbyte-cryptoToolbox = { group = "com.onixbyte", name = "crypto-toolbox", version.ref = "onixbyteVersion" } onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" } onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" } onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" } diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java new file mode 100644 index 0000000..2f8e111 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java @@ -0,0 +1,85 @@ +package com.onixbyte.deltaforceguide.interceptor; + +import com.onixbyte.crypto.util.CryptoUtil; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.manager.WebhookManager; +import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; +import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Verifies GitHub webhook requests by validating the {@code X-Hub-Signature-256} + * header against the configured secret using HMAC-SHA256. + * + *

Verification is skipped when no secret is configured. The signature format is + * {@code sha256=} as documented by GitHub. + */ +@Component +public class GitHubWebhookInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(GitHubWebhookInterceptor.class); + + private final WebhookManager webhookManager; + + public GitHubWebhookInterceptor(WebhookManager webhookManager) { + this.webhookManager = webhookManager; + } + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (!(request instanceof RepeatedlyReadRequestWrapper req)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Request body is not readable"); + } + + var secret = webhookManager.github().secret(); + if (secret == null || secret.isBlank()) { + log.debug("No GitHub webhook secret configured, skipping signature verification"); + return true; + } + + var signatureHeader = req.getHeader(GitHubWebhookHeader.SIGNATURE_256); + if (signatureHeader == null || signatureHeader.isBlank()) { + log.warn("Missing {} header from ip={}", + GitHubWebhookHeader.SIGNATURE_256, request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, + "Missing webhook signature header"); + } + + var body = req.getBodyString(); + try { + var computed = "sha256=" + CryptoUtil.hmacSha256(secret, body); + + if (!MessageDigest.isEqual( + computed.getBytes(StandardCharsets.UTF_8), + signatureHeader.getBytes(StandardCharsets.UTF_8))) { + log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature"); + } + + return true; + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + log.error("Failed to compute HMAC-SHA256", e); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to verify webhook signature"); + } + } +} From 7fafa0d90356045a4b27fb477290f384d790df6b Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 15:30:14 +0800 Subject: [PATCH 069/117] refactor: extract ModificationManager for modification creation Move create/batchCreate transactional logic from ModificationService into a dedicated ModificationManager. Both ModificationService and WebhookService delegate to the manager, respecting the Controller -> Service -> Manager layering rule. --- .../manager/ModificationManager.java | 115 ++++++++++++++++++ .../service/ModificationService.java | 47 +------ 2 files changed, 121 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java new file mode 100644 index 0000000..eb0007a --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java @@ -0,0 +1,115 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.domain.entity.Accessory; +import com.onixbyte.deltaforceguide.domain.entity.Firearm; +import com.onixbyte.deltaforceguide.domain.entity.Modification; +import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; +import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; +import com.onixbyte.deltaforceguide.repository.FirearmRepository; +import com.onixbyte.deltaforceguide.repository.ModificationRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +@Component +public class ModificationManager { + + private final ModificationRepository modificationRepository; + private final FirearmRepository firearmRepository; + + public ModificationManager( + ModificationRepository modificationRepository, + FirearmRepository firearmRepository + ) { + this.modificationRepository = modificationRepository; + this.firearmRepository = firearmRepository; + } + + @Transactional + public ModificationResponse create(ModificationRequest request) { + var firearm = firearmRepository.findById(request.firearmId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Firearm not found: " + request.firearmId())); + var modification = toEntity(request, firearm); + return ModificationResponse.from(modificationRepository.save(modification)); + } + + @Transactional + public List batchCreate(List requests) { + var firearmIds = requests.stream() + .map(ModificationRequest::firearmId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + + Map firearmMap = new HashMap<>(); + firearmRepository.findAllById(firearmIds) + .forEach(firearm -> firearmMap.put(firearm.getId(), firearm)); + + if (firearmMap.size() != firearmIds.size()) { + var missing = firearmIds.stream() + .filter((id) -> !firearmMap.containsKey(id)) + .toList(); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Firearm not found: " + missing); + } + + var modifications = requests.stream() + .map(req -> toEntity(req, firearmMap.get(req.firearmId()))) + .toList(); + return modificationRepository.saveAll(modifications) + .stream() + .map(ModificationResponse::from) + .toList(); + } + + private Modification toEntity(ModificationRequest request, Firearm firearm) { + return Modification.builder() + .firearm(firearm) + .name(request.name()) + .code(request.code()) + .tags(request.tags()) + .accessories(toAccessories(request.accessories())) + .note(request.note()) + .author(request.author()) + .videoUrl(request.videoUrl()) + .build(); + } + + private List toAccessories(List requests) { + if (requests == null) { + return new ArrayList<>(); + } + return requests.stream().map(this::toAccessory).toList(); + } + + private Accessory toAccessory(AccessoryRequest request) { + var accessory = new Accessory(); + accessory.setSlotName(request.slotName()); + accessory.setAccessoryName(request.accessoryName()); + accessory.setTunings(toTunings(request.tunings())); + return accessory; + } + + private List toTunings(List requests) { + if (requests == null) { + return new ArrayList<>(); + } + return requests.stream().map(this::toTuning).toList(); + } + + private Tuning toTuning(TuningRequest request) { + var tuning = new Tuning(); + tuning.setTuningName(request.tuningName()); + tuning.setTuningValue(request.tuningValue()); + return tuning; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index 39e5ec0..a5129af 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -9,6 +9,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Modification; import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository; import com.fasterxml.jackson.core.JsonProcessingException; @@ -21,10 +22,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Set; @Service @@ -32,15 +31,18 @@ public class ModificationService { private final ModificationRepository modificationRepository; private final FirearmRepository firearmRepository; + private final ModificationManager modificationManager; private final ObjectMapper objectMapper; public ModificationService( ModificationRepository modificationRepository, FirearmRepository firearmRepository, + ModificationManager modificationManager, ObjectMapper objectMapper ) { this.modificationRepository = modificationRepository; this.firearmRepository = firearmRepository; + this.modificationManager = modificationManager; this.objectMapper = objectMapper; } @@ -79,36 +81,12 @@ public class ModificationService { @Transactional public ModificationResponse create(ModificationRequest request) { - Firearm firearm = firearmRepository.findById(request.firearmId()) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId())); - - Modification modification = toEntity(request, firearm); - return ModificationResponse.from(modificationRepository.save(modification)); + return modificationManager.create(request); } @Transactional public List batchCreate(List requests) { - Set firearmIds = requests.stream() - .map(ModificationRequest::firearmId) - .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); - - Map firearmMap = new HashMap<>(); - firearmRepository.findAllById(firearmIds).forEach(firearm -> firearmMap.put(firearm.getId(), firearm)); - - if (firearmMap.size() != firearmIds.size()) { - List missingFirearmIds = firearmIds.stream() - .filter(id -> !firearmMap.containsKey(id)) - .toList(); - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + missingFirearmIds); - } - - List modifications = requests.stream() - .map(request -> toEntity(request, firearmMap.get(request.firearmId()))) - .toList(); - return modificationRepository.saveAll(modifications) - .stream() - .map(ModificationResponse::from) - .toList(); + return modificationManager.batchCreate(requests); } @Transactional @@ -155,19 +133,6 @@ public class ModificationService { modificationRepository.deleteAllInBatch(modifications); } - private Modification toEntity(ModificationRequest request, Firearm firearm) { - return Modification.builder() - .firearm(firearm) - .name(request.name()) - .code(request.code()) - .tags(safeTags(request.tags())) - .accessories(toAccessories(request.accessories())) - .note(request.note()) - .author(request.author()) - .videoUrl(request.videoUrl()) - .build(); - } - private List safeTags(List tags) { return tags == null ? new ArrayList<>() : tags; } From c30b5701e4eadbfc5eac77a2dbd0defc921a00c9 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 15:30:30 +0800 Subject: [PATCH 070/117] feat: implement GitHub webhook controller and processing service WebhookService parses YAML-fenced issue body, filters by "weapon-mod" label, deduplicates via Redis SETNX with 12h TTL, and delegates to ModificationManager for single/batch creation. GitHubWebhookController verifies X-GitHub-Event=issues and action=opened before delegating to the service. Register GitHubWebhookInterceptor for /webhooks/github in AppConfig. --- .../deltaforceguide/config/AppConfig.java | 12 +- .../controller/GitHubWebhookController.java | 43 ++++ .../service/WebhookService.java | 186 +++++++++++++++++- 3 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index de34b6f..6bba2db 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -1,7 +1,7 @@ package com.onixbyte.deltaforceguide.config; +import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor; import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor; -import com.onixbyte.deltaforceguide.interceptor.GitLabWebhookInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -15,21 +15,21 @@ public class AppConfig implements WebMvcConfigurer { private final TrafficInterceptor trafficInterceptor; - private final GitLabWebhookInterceptor gitLabWebhookInterceptor; + private final GitHubWebhookInterceptor gitHubWebhookInterceptor; @Autowired public AppConfig( TrafficInterceptor trafficInterceptor, - GitLabWebhookInterceptor gitLabWebhookInterceptor + GitHubWebhookInterceptor gitHubWebhookInterceptor ) { this.trafficInterceptor = trafficInterceptor; - this.gitLabWebhookInterceptor = gitLabWebhookInterceptor; + this.gitHubWebhookInterceptor = gitHubWebhookInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(trafficInterceptor); - registry.addInterceptor(gitLabWebhookInterceptor) - .addPathPatterns("/webhook/gitlab"); + registry.addInterceptor(gitHubWebhookInterceptor) + .addPathPatterns("/webhooks/github"); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java new file mode 100644 index 0000000..580e1f9 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java @@ -0,0 +1,43 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; +import com.onixbyte.deltaforceguide.service.WebhookService; +import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/webhooks/github") +public class GitHubWebhookController { + + private static final Logger log = LoggerFactory.getLogger(GitHubWebhookController.class); + + private final WebhookService webhookService; + + public GitHubWebhookController(WebhookService webhookService) { + this.webhookService = webhookService; + } + + @PostMapping + public ResponseEntity handleWebhook( + @RequestHeader(GitHubWebhookHeader.EVENT) String event, + @RequestBody GitHubIssueRequest request + ) { + if (!"issues".equals(event)) { + log.debug("Ignoring non-issue event: {}", event); + return ResponseEntity.ok().build(); + } + if (!"opened".equals(request.action())) { + log.debug("Ignoring issue action: {}", request.action()); + return ResponseEntity.ok().build(); + } + webhookService.processIssueEvent(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index 4a1f50d..3489c2c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -1,14 +1,192 @@ package com.onixbyte.deltaforceguide.service; -import com.onixbyte.deltaforceguide.manager.WebhookManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; +import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; +import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; +import com.onixbyte.deltaforceguide.manager.ModificationManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import org.yaml.snakeyaml.Yaml; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; @Service public class WebhookService { - private final WebhookManager webhookManager; + private static final Logger log = LoggerFactory.getLogger(WebhookService.class); + private static final String TRIGGER_LABEL = "weapon-mod"; + private static final Duration DEDUP_TTL = Duration.ofHours(12); + private static final Pattern YAML_FENCE = + Pattern.compile("```ya?ml\\s*\\n(.*?)```", Pattern.DOTALL); - public WebhookService(WebhookManager webhookManager) { - this.webhookManager = webhookManager; + private final ModificationManager modificationManager; + private final RedisTemplate redisTemplate; + private final Yaml yaml; + + public WebhookService( + ModificationManager modificationManager, + RedisTemplate redisTemplate + ) { + this.modificationManager = modificationManager; + this.redisTemplate = redisTemplate; + this.yaml = new Yaml(); + } + + public void processIssueEvent(GitHubIssueRequest request) { + var issue = request.issue(); + var repository = request.repository(); + + if (!hasTriggerLabel(issue.labels())) { + log.debug("Issue #{} lacks trigger label, skipping", issue.number()); + return; + } + + var dedupKey = "github:webhook:processed:%s:%d" + .formatted(repository.fullName(), issue.number()); + var acquired = redisTemplate.opsForValue() + .setIfAbsent(dedupKey, "1", DEDUP_TTL); + if (acquired == null || !acquired) { + log.info("Issue #{} already processed, skipping", issue.number()); + return; + } + + var parsedYaml = extractYaml(issue.body()); + if (parsedYaml == null) { + log.warn("No YAML block found in issue #{} body", issue.number()); + return; + } + + @SuppressWarnings("unchecked") + var data = (Map) yaml.load(parsedYaml); + if (data == null) { + log.warn("Empty YAML block in issue #{}", issue.number()); + return; + } + + if (data.containsKey("modifications")) { + processBatch(issue.number(), data); + } else { + processSingle(issue.number(), data); + } + } + + private void processSingle(Long issueNumber, Map data) { + var request = mapToRequest(data); + log.info("Creating modification from issue #{}: name={}", issueNumber, request.name()); + modificationManager.create(request); + } + + @SuppressWarnings("unchecked") + private void processBatch(Long issueNumber, Map data) { + var list = (List>) data.get("modifications"); + if (list == null || list.isEmpty()) { + log.warn("Empty modifications list in issue #{}", issueNumber); + return; + } + var requests = list.stream() + .map(this::mapToRequest) + .toList(); + log.info("Batch creating {} modifications from issue #{}", requests.size(), issueNumber); + modificationManager.batchCreate(requests); + } + + private ModificationRequest mapToRequest(Map data) { + Long firearmId = toLong(data.get("firearmId")); + String name = (String) data.get("name"); + String code = (String) data.get("code"); + List tags = toStringList(data.get("tags")); + List accessories = mapAccessories(data.get("accessories")); + String note = (String) data.get("note"); + String author = (String) data.get("author"); + String videoUrl = (String) data.get("videoUrl"); + + return new ModificationRequest(firearmId, name, code, tags, accessories, + note, author, videoUrl); + } + + private List mapAccessories(Object raw) { + if (!(raw instanceof List list)) { + return new ArrayList<>(); + } + var result = new ArrayList(); + for (var item : list) { + if (item instanceof Map map) { + result.add(new AccessoryRequest( + (String) map.get("slotName"), + (String) map.get("accessoryName"), + mapTunings(map.get("tunings")) + )); + } + } + return result; + } + + private List mapTunings(Object raw) { + if (!(raw instanceof List list)) { + return new ArrayList<>(); + } + var result = new ArrayList(); + for (var item : list) { + if (item instanceof Map map) { + result.add(new TuningRequest( + (String) map.get("tuningName"), + toDouble(map.get("tuningValue")) + )); + } + } + return result; + } + + private List toStringList(Object raw) { + if (raw instanceof List list) { + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + return new ArrayList<>(); + } + + private boolean hasTriggerLabel(List labels) { + if (labels == null) { + return false; + } + return labels.stream().anyMatch(label -> TRIGGER_LABEL.equals(label.name())); + } + + private String extractYaml(String body) { + if (body == null) { + return null; + } + var matcher = YAML_FENCE.matcher(body); + return matcher.find() ? matcher.group(1) : null; + } + + private Long toLong(Object value) { + if (value instanceof Number num) { + return num.longValue(); + } + if (value instanceof String s) { + return Long.parseLong(s); + } + return null; + } + + private Double toDouble(Object value) { + if (value instanceof Number num) { + return num.doubleValue(); + } + if (value instanceof String s) { + return Double.parseDouble(s); + } + return null; } } From 8a9cf110af841d658760af393d8cec4d6c7db954 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 15:37:06 +0800 Subject: [PATCH 071/117] chore: remove deprecated GitLab webhook code GitLab webhook has been superseded by the GitHub webhook implementation. Remove WebhookController (formerly GitLabWebhookController), GitLabWebhookRequest DTO, and GitLabWebhookInterceptor. --- .../controller/WebhookController.java | 20 --- .../domain/dto/GitLabWebhookRequest.java | 45 ------ .../interceptor/GitLabWebhookInterceptor.java | 152 ------------------ 3 files changed, 217 deletions(-) delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java deleted file mode 100644 index b882977..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.onixbyte.deltaforceguide.controller; - -import com.onixbyte.deltaforceguide.domain.dto.GitLabWebhookRequest; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/webhooks") -public class WebhookController { - - private static final Logger log = LoggerFactory.getLogger(WebhookController.class); - - @PostMapping("/gitlab") - public void gitlabWebhook( - @RequestBody GitLabWebhookRequest request - ) { - log.info("request={}", request); - } -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java deleted file mode 100644 index b48ec75..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitLabWebhookRequest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.onixbyte.deltaforceguide.domain.dto; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import java.time.OffsetDateTime; -import java.util.List; - -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -@JsonIgnoreProperties(ignoreUnknown = true) -public record GitLabWebhookRequest( - String objectKind, - String eventType, - GitLabWebhookObjectAttributes objectAttributes -) { - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - @JsonIgnoreProperties(ignoreUnknown = true) - public record GitLabWebhookLabel( - Long id, - String title, - @JsonProperty("color") - String colour, - Long projectId, - String createdAt, - String updatedAt, - Boolean template, - String description, - String type, - Long groupId - ) {} - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - @JsonIgnoreProperties(ignoreUnknown = true) - public record GitLabWebhookObjectAttributes( - Long id, - String title, - String description, - List labels - ) {} - - -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java deleted file mode 100644 index f933928..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitLabWebhookInterceptor.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.onixbyte.deltaforceguide.interceptor; - -import com.onixbyte.deltaforceguide.exeption.BizException; -import com.onixbyte.deltaforceguide.manager.WebhookManager; -import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.jspecify.annotations.NonNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerInterceptor; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; - -/** - * Verifies GitLab webhook requests by validating the {@code webhook-id}, - * {@code webhook-timestamp}, and {@code webhook-signature} headers against - * a configured signing token using HMAC-SHA256. - * - *

Supports GitLab's v1 signature scheme. Verification is skipped when no - * signing token is configured. - */ -@Component -public class GitLabWebhookInterceptor implements HandlerInterceptor { - - private static final Logger log = LoggerFactory.getLogger(GitLabWebhookInterceptor.class); - private static final String TOKEN_PREFIX = "whsec_"; - - private final WebhookManager webhookManager; - - /** - * Creates a new interceptor with the given webhook configuration. - * - * @param webhookManager the webhook manager - */ - public GitLabWebhookInterceptor(WebhookManager webhookManager) { - this.webhookManager = webhookManager; - } - - /** - * Validates the GitLab webhook signature headers on the incoming request. - *

- * Reads {@code webhook-id}, {@code webhook-timestamp}, and - * {@code webhook-signature} headers and verifies the signature against the - * configured signing token. If no token is configured, verification is skipped. - * - * @param request the incoming HTTP request (must be a {@link RepeatedlyReadRequestWrapper}) - * @param response the HTTP response - * @param handler the chosen handler to execute - * @return {@code true} if the request is authentic - * @throws BizException with {@code 401} if headers are missing or the signature is invalid, - * with {@code 500} if verification fails unexpectedly - */ - @Override - public boolean preHandle( - @NonNull HttpServletRequest request, - @NonNull HttpServletResponse response, - @NonNull Object handler - ) { - if (!(request instanceof RepeatedlyReadRequestWrapper req)) { - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, - "Request body is not readable"); - } - - var webhookId = request.getHeader("webhook-id"); - var webhookTimestamp = request.getHeader("webhook-timestamp"); - var webhookSignature = request.getHeader("webhook-signature"); - - if (webhookId == null || webhookTimestamp == null || webhookSignature == null) { - log.warn("Missing webhook headers from ip={}", request.getRemoteAddr()); - throw new BizException(HttpStatus.UNAUTHORIZED, - "Missing webhook verification headers"); - } - - var signingToken = webhookManager.getGitLabWebhookProperties().signingToken(); - if (signingToken == null || signingToken.isBlank()) { - log.debug("No GitLab signing token configured, skipping signature verification"); - return true; - } - - var body = req.getBodyString(); - var signedContent = "%s.%s.%s".formatted(webhookId, webhookTimestamp, body); - - try { - var decodedKey = decodeSigningToken(signingToken); - var computedDigest = computeHmacSha256(decodedKey, signedContent); - var computedSignature = "v1,%s".formatted(computedDigest); - - var signatures = webhookSignature.split(" "); - var matched = false; - for (var sig : signatures) { - if (MessageDigest.isEqual(computedSignature.getBytes(StandardCharsets.UTF_8), - sig.trim().getBytes(StandardCharsets.UTF_8))) { - matched = true; - break; - } - } - - if (!matched) { - log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr()); - throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature"); - } - } catch (BizException e) { - throw e; - } catch (Exception e) { - log.error("Failed to verify webhook signature", e); - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, - "Failed to verify webhook signature"); - } - - return true; - } - - /** - * Decodes a GitLab-format signing token by stripping the {@code whsec_} prefix - * and Base64-decoding the remainder. - * - * @param token the prefixed signing token - * @return the raw key bytes - * @throws BizException if the token does not start with {@code whsec_} - */ - private byte[] decodeSigningToken(String token) { - if (!token.startsWith(TOKEN_PREFIX)) { - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, - "Signing token must start with " + TOKEN_PREFIX); - } - var encoded = token.substring(TOKEN_PREFIX.length()); - return Base64.getDecoder().decode(encoded); - } - - /** - * Computes the Base64-encoded HMAC-SHA256 digest for the given data. - * - * @param key the secret key bytes - * @param data the content to sign - * @return Base64-encoded HMAC-SHA256 digest - * @throws Exception if the HMAC algorithm is unavailable - */ - private String computeHmacSha256(byte[] key, String data) throws Exception { - var mac = Mac.getInstance("HmacSHA256"); - var secretKey = new SecretKeySpec(key, "HmacSHA256"); - mac.init(secretKey); - var hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(hash); - } -} From 0530c1f633feaca21635a18cbb1f8f9986433f1c Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 16:30:46 +0800 Subject: [PATCH 072/117] feat: add allowed-users sender filtering for GitHub webhook Only issues submitted by users in the allowed-users list are processed. An empty or null list allows all senders (no filtering). Checks sender.login from the webhook payload against the configured list. --- .../domain/dto/GitHubIssueRequest.java | 3 ++- .../domain/dto/GitHubWebhookSender.java | 12 +++++++++ .../service/WebhookService.java | 27 +++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookSender.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java index 449591b..8c4e1a6 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; public record GitHubIssueRequest( String action, GitHubWebhookIssue issue, - GitHubWebhookRepository repository + GitHubWebhookRepository repository, + GitHubWebhookSender sender ) { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookSender.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookSender.java new file mode 100644 index 0000000..416891e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookSender.java @@ -0,0 +1,12 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubWebhookSender( + String login +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index 3489c2c..f54f39c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -1,11 +1,11 @@ package com.onixbyte.deltaforceguide.service; -import com.fasterxml.jackson.databind.ObjectMapper; import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; import com.onixbyte.deltaforceguide.manager.ModificationManager; +import com.onixbyte.deltaforceguide.manager.WebhookManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.RedisTemplate; @@ -29,20 +29,30 @@ public class WebhookService { private final ModificationManager modificationManager; private final RedisTemplate redisTemplate; + private final WebhookManager webhookManager; private final Yaml yaml; public WebhookService( ModificationManager modificationManager, - RedisTemplate redisTemplate + RedisTemplate redisTemplate, + WebhookManager webhookManager ) { this.modificationManager = modificationManager; this.redisTemplate = redisTemplate; + this.webhookManager = webhookManager; this.yaml = new Yaml(); } public void processIssueEvent(GitHubIssueRequest request) { var issue = request.issue(); var repository = request.repository(); + var sender = request.sender(); + + if (!isAllowedSender(sender)) { + log.info("Issue #{} sender={} not in allowed-users, skipping", + issue.number(), sender != null ? sender.login() : "null"); + return; + } if (!hasTriggerLabel(issue.labels())) { log.debug("Issue #{} lacks trigger label, skipping", issue.number()); @@ -155,6 +165,19 @@ public class WebhookService { return new ArrayList<>(); } + private boolean isAllowedSender( + com.onixbyte.deltaforceguide.domain.dto.GitHubWebhookSender sender + ) { + var allowedUsers = webhookManager.github().allowedUsers(); + if (allowedUsers == null || allowedUsers.isEmpty()) { + return true; + } + if (sender == null || sender.login() == null) { + return false; + } + return allowedUsers.contains(sender.login()); + } + private boolean hasTriggerLabel(List labels) { if (labels == null) { return false; From 4ee741d307a233851f0bdc05af6d2445c74b9615 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 16:34:37 +0800 Subject: [PATCH 073/117] feat: add firearm name lookup for webhook YAML parsing When firearmId is absent from the YAML block, resolveFirearmId falls back to firearmName lookup via FirearmRepository.findByName(). If both are present, firearmId takes precedence. --- .../manager/ModificationManager.java | 15 +++++++++++++++ .../repository/FirearmRepository.java | 4 ++++ .../deltaforceguide/service/WebhookService.java | 4 +++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java index eb0007a..f7defab 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java @@ -71,6 +71,21 @@ public class ModificationManager { .toList(); } + public Long resolveFirearmId(Long firearmId, String firearmName) { + if (firearmId != null) { + return firearmId; + } + if (firearmName == null || firearmName.isBlank()) { + return null; + } + var matches = firearmRepository.findByName(firearmName); + if (matches.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Firearm not found by name: " + firearmName); + } + return matches.getFirst().getId(); + } + private Modification toEntity(ModificationRequest request, Firearm firearm) { return Modification.builder() .firearm(firearm) diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index dcf85f8..b486703 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -7,9 +7,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface FirearmRepository extends JpaRepository { Page findAllByType(FirearmType type, Pageable pageable); + + List findByName(String name); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index f54f39c..91f454b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -109,7 +109,9 @@ public class WebhookService { } private ModificationRequest mapToRequest(Map data) { - Long firearmId = toLong(data.get("firearmId")); + Long firearmId = modificationManager.resolveFirearmId( + toLong(data.get("firearmId")), + (String) data.get("firearmName")); String name = (String) data.get("name"); String code = (String) data.get("code"); List tags = toStringList(data.get("tags")); From 20bc18d416cb393542a951321d7e1b66a9169ae5 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 16:59:05 +0800 Subject: [PATCH 074/117] fix: rename misnamed loggingFilter bean to webhookFilter --- .../java/com/onixbyte/deltaforceguide/config/FilterConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java index 40442ab..b0c3e0f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java @@ -9,7 +9,7 @@ import org.springframework.context.annotation.Configuration; public class FilterConfig { @Bean - public FilterRegistrationBean loggingFilter(WebhookFilter webhookFilter) { + public FilterRegistrationBean webhookFilter(WebhookFilter webhookFilter) { var registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(webhookFilter); From 3616ad9eab05a297b9bee99e8d0faa9812b1e0c5 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 16:59:08 +0800 Subject: [PATCH 075/117] chore: remove unused imports in GitHubWebhookInterceptor --- .../deltaforceguide/interceptor/GitHubWebhookInterceptor.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java index 2f8e111..9aacbe7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java @@ -14,8 +14,6 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.MessageDigest; From de61e1feb7bfb025cb3d3eba06748f18b2cb6dba Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 16:59:12 +0800 Subject: [PATCH 076/117] refactor: remove redundant @Transactional from delegated service methods --- .../onixbyte/deltaforceguide/service/ModificationService.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index a5129af..37aa1d4 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -79,12 +79,10 @@ public class ModificationService { return modificationRepository.findAllTags(firearmId); } - @Transactional public ModificationResponse create(ModificationRequest request) { return modificationManager.create(request); } - @Transactional public List batchCreate(List requests) { return modificationManager.batchCreate(requests); } From ed2a0f4ae0ad1bbf909a50bd8c83340f30c62079 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 16:59:40 +0800 Subject: [PATCH 077/117] style: replace fully qualified type names and clean up imports --- .../onixbyte/deltaforceguide/service/WebhookService.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index 91f454b..a0ffed4 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -1,9 +1,6 @@ package com.onixbyte.deltaforceguide.service; -import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; -import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; -import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; -import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; +import com.onixbyte.deltaforceguide.domain.dto.*; import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.manager.WebhookManager; import org.slf4j.Logger; @@ -168,7 +165,7 @@ public class WebhookService { } private boolean isAllowedSender( - com.onixbyte.deltaforceguide.domain.dto.GitHubWebhookSender sender + GitHubWebhookSender sender ) { var allowedUsers = webhookManager.github().allowedUsers(); if (allowedUsers == null || allowedUsers.isEmpty()) { @@ -180,7 +177,7 @@ public class WebhookService { return allowedUsers.contains(sender.login()); } - private boolean hasTriggerLabel(List labels) { + private boolean hasTriggerLabel(List labels) { if (labels == null) { return false; } From b0c41e08ea8cba3f421eda5121f1bbea2972a000 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 16:59:47 +0800 Subject: [PATCH 078/117] fix: use \R instead of \n in YAML fence regex for cross-platform linebreaks --- .../com/onixbyte/deltaforceguide/service/WebhookService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index a0ffed4..b9b394c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -22,7 +22,7 @@ public class WebhookService { private static final String TRIGGER_LABEL = "weapon-mod"; private static final Duration DEDUP_TTL = Duration.ofHours(12); private static final Pattern YAML_FENCE = - Pattern.compile("```ya?ml\\s*\\n(.*?)```", Pattern.DOTALL); + Pattern.compile("```ya?ml\\s*\\R(.*?)```", Pattern.DOTALL); private final ModificationManager modificationManager; private final RedisTemplate redisTemplate; From 5b5062aae9dbca391a7d506260fdb19479ae0a43 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 17:00:03 +0800 Subject: [PATCH 079/117] fix: validate firearmId is present before constructing ModificationRequest --- .../onixbyte/deltaforceguide/service/WebhookService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index b9b394c..44f57db 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -6,7 +6,9 @@ import com.onixbyte.deltaforceguide.manager.WebhookManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; import org.yaml.snakeyaml.Yaml; import java.time.Duration; @@ -109,6 +111,10 @@ public class WebhookService { Long firearmId = modificationManager.resolveFirearmId( toLong(data.get("firearmId")), (String) data.get("firearmName")); + if (firearmId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "YAML must contain firearmId or firearmName"); + } String name = (String) data.get("name"); String code = (String) data.get("code"); List tags = toStringList(data.get("tags")); From 9594efe716d94b0bf4da86ef8fa067783a67953d Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 17:01:50 +0800 Subject: [PATCH 080/117] fix: use BizException instead of ResponseStatusException for firearmId validation --- .../com/onixbyte/deltaforceguide/service/WebhookService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index 44f57db..3476958 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -8,7 +8,7 @@ import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; +import com.onixbyte.deltaforceguide.exeption.BizException; import org.yaml.snakeyaml.Yaml; import java.time.Duration; @@ -112,7 +112,7 @@ public class WebhookService { toLong(data.get("firearmId")), (String) data.get("firearmName")); if (firearmId == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + throw new BizException(HttpStatus.BAD_REQUEST, "YAML must contain firearmId or firearmName"); } String name = (String) data.get("name"); From eb2d9b33695adc9e7e39d6c21fea8f4c376701c5 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 1 Jun 2026 17:18:42 +0800 Subject: [PATCH 081/117] fix: rename webhook filter bean to avoid name collision with WebhookFilter class --- .../java/com/onixbyte/deltaforceguide/config/FilterConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java index b0c3e0f..893852a 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java @@ -9,7 +9,7 @@ import org.springframework.context.annotation.Configuration; public class FilterConfig { @Bean - public FilterRegistrationBean webhookFilter(WebhookFilter webhookFilter) { + public FilterRegistrationBean webhookFilterBean(WebhookFilter webhookFilter) { var registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(webhookFilter); From d323e4f8f7bd683e19e211c93eeb873262ae76e4 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 2 Jun 2026 01:46:49 +0800 Subject: [PATCH 082/117] chore: change traffic interceptor log level from info to debug --- .../deltaforceguide/interceptor/TrafficInterceptor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java index ab3b657..c7aac50 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java @@ -27,7 +27,7 @@ public class TrafficInterceptor implements HandlerInterceptor { var contentLength = request.getContentLength(); var userAgent = request.getHeader("User-Agent"); - log.info("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}", + log.debug("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}", method, uri, query, ip, contentType, contentLength, userAgent); return true; } From 0815d1d6186bfcde741b230acd1ad7870589d596 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 4 Jun 2026 14:42:14 +0800 Subject: [PATCH 083/117] chore: optimise code style --- .../deltaforceguide/interceptor/GitHubWebhookInterceptor.java | 2 +- .../com/onixbyte/deltaforceguide/service/WebhookService.java | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java index 9aacbe7..40df977 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java @@ -64,7 +64,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor { var body = req.getBodyString(); try { - var computed = "sha256=" + CryptoUtil.hmacSha256(secret, body); + var computed = "sha256=" + CryptoUtil.hmacSha256(body, secret); if (!MessageDigest.isEqual( computed.getBytes(StandardCharsets.UTF_8), diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index 3476958..c5c2b33 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -73,8 +73,7 @@ public class WebhookService { return; } - @SuppressWarnings("unchecked") - var data = (Map) yaml.load(parsedYaml); + var data = yaml.>load(parsedYaml); if (data == null) { log.warn("Empty YAML block in issue #{}", issue.number()); return; From 4e2da0debce14b94aad76dedce4c02a7a36bc7e1 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 4 Jun 2026 14:47:45 +0800 Subject: [PATCH 084/117] feat: add expire time into login response --- .../deltaforceguide/controller/AuthController.java | 5 ++++- .../deltaforceguide/domain/dto/UserResponse.java | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index 0a42067..db0f42e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.time.Duration; +import java.time.LocalDateTime; /** * REST controller for user authentication endpoints (login, logout). @@ -43,12 +44,14 @@ public class AuthController { @PostMapping("/login") public ResponseEntity login(@Validated @RequestBody LoginRequest request) { var user = authService.login(request); + var currentTime = LocalDateTime.now(); var accessToken = tokenClient.generateToken(user); var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken); + var cookieMaxAge = accessTokenCookie.getMaxAge(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - .body(UserResponse.from(user)); + .body(UserResponse.from(user, currentTime.plus(cookieMaxAge))); } @RequiresAuth diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java index b20231b..4a2d82d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java @@ -2,6 +2,8 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.domain.entity.User; +import java.time.LocalDateTime; + /** * Response DTO for a user account, including associated credentials. * @@ -10,13 +12,15 @@ import com.onixbyte.deltaforceguide.domain.entity.User; public record UserResponse( Long id, String username, - String email + String email, + LocalDateTime expiration ) { - public static UserResponse from(User user) { + public static UserResponse from(User user, LocalDateTime expiration) { return new UserResponse( user.getId(), user.getUsername(), - user.getEmail() + user.getEmail(), + expiration ); } } From 17cd87c7025d8317aa14b3af6a9492c479c38684 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 4 Jun 2026 17:12:48 +0800 Subject: [PATCH 085/117] feat: inject build-time variables via Gradle processResources Replace hardcoded AppProperties values with Gradle ${} placeholders, allowing version/channel/vendor to be configured via gradle.properties or -P flags at build time. Also refactor webhook configuration to flatten the properties hierarchy by removing the intermediate WebhookProperties wrapper. --- build.gradle.kts | 12 ++++++++++++ .../deltaforceguide/config/WebhookConfig.java | 4 ++-- .../interceptor/GitHubWebhookInterceptor.java | 2 +- .../deltaforceguide/manager/AppManager.java | 6 +++++- .../manager/WebhookManager.java | 19 +++++++++++-------- .../properties/AppProperties.java | 4 +++- .../properties/GitHubWebhookProperties.java | 3 +++ .../properties/WebhookProperties.java | 9 --------- .../service/WebhookService.java | 2 +- src/main/resources/application.yaml | 12 ++++++------ 10 files changed, 44 insertions(+), 29 deletions(-) delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java diff --git a/build.gradle.kts b/build.gradle.kts index 878912f..a98947a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,8 @@ plugins { } val artefactVersion: String by project +val buildChannel: String by project +val vendor: String by project group = "com.onixbyte.helix" version = artefactVersion @@ -61,6 +63,16 @@ dependencies { testRuntimeOnly(libs.junit.launcher) } +tasks.processResources { + filesMatching("application.yaml") { + expand( + "appVersion" to artefactVersion, + "channel" to buildChannel, + "vendor" to vendor + ) + } +} + tasks.test { useJUnitPlatform() } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java index 1cb1a6c..d9c5db7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java @@ -1,10 +1,10 @@ package com.onixbyte.deltaforceguide.config; -import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties(WebhookProperties.class) +@EnableConfigurationProperties({GitHubWebhookProperties.class}) public class WebhookConfig { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java index 40df977..c1b552a 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java @@ -48,7 +48,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor { "Request body is not readable"); } - var secret = webhookManager.github().secret(); + var secret = webhookManager.secret(); if (secret == null || secret.isBlank()) { log.debug("No GitHub webhook secret configured, skipping signature verification"); return true; diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java index 64f8ddd..378fe9b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java @@ -18,6 +18,10 @@ public class AppManager { * @return the version string of this application */ public String getVersion() { - return appProperties.version(); + return "v%s-%s by @%s".formatted( + appProperties.version(), + appProperties.channel(), + appProperties.vendor() + ); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java index 1e63f36..21c5b52 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java @@ -1,21 +1,24 @@ package com.onixbyte.deltaforceguide.manager; import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties; -import com.onixbyte.deltaforceguide.properties.WebhookProperties; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class WebhookManager { - private final WebhookProperties webhookProperties; + private final GitHubWebhookProperties gitHubWebhookProperties; - @Autowired - public WebhookManager(WebhookProperties webhookProperties) { - this.webhookProperties = webhookProperties; + public WebhookManager(GitHubWebhookProperties gitHubWebhookProperties) { + this.gitHubWebhookProperties = gitHubWebhookProperties; } - public GitHubWebhookProperties github() { - return webhookProperties.github(); + public String secret() { + return gitHubWebhookProperties.secret(); + } + + public List allowedUsers() { + return gitHubWebhookProperties.allowedUsers(); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java index 79b056c..b13bca2 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java @@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "app.common") public record AppProperties( - String version + String version, + String channel, + String vendor ) { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java index bf91c6a..673051a 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java @@ -1,7 +1,10 @@ package com.onixbyte.deltaforceguide.properties; +import org.springframework.boot.context.properties.ConfigurationProperties; + import java.util.List; +@ConfigurationProperties(prefix = "app.webhook.github") public record GitHubWebhookProperties( String secret, List allowedUsers diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java deleted file mode 100644 index d420105..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.onixbyte.deltaforceguide.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "app.webhook") -public record WebhookProperties( - GitHubWebhookProperties github -) { -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index c5c2b33..e864a77 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -172,7 +172,7 @@ public class WebhookService { private boolean isAllowedSender( GitHubWebhookSender sender ) { - var allowedUsers = webhookManager.github().allowedUsers(); + var allowedUsers = webhookManager.allowedUsers(); if (allowedUsers == null || allowedUsers.isEmpty()) { return true; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 74de069..8fcdf2f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -39,13 +39,13 @@ mybatis: type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler mapper-locations: classpath:/mapper/*.xml -app: - webhook: - github: - secret: ${GITHUB_WEBHOOK_SECRET:} - allowed-users: [] - logging: level: org.hibernate: orm.connections.pooling: off + +app: + common: + version: ${appVersion} + channel: ${channel} + vendor: ${vendor} From a065b60cae0de0bf7c7c75668fe8327ab5ad9b8a Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 4 Jun 2026 17:36:43 +0800 Subject: [PATCH 086/117] chore: migrate CI from GitHub Actions to Gitea Actions Replace GitHub Actions workflow with Gitea Actions, and switch container registry from GHCR to Docker Hub. --- .gitea/workflows/build-and-deploy.yml | 72 +++++++++++++++++++++ .github/workflows/build-and-deploy.yml | 86 -------------------------- 2 files changed, 72 insertions(+), 86 deletions(-) create mode 100644 .gitea/workflows/build-and-deploy.yml delete mode 100644 .github/workflows/build-and-deploy.yml diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml new file mode 100644 index 0000000..1d83a90 --- /dev/null +++ b/.gitea/workflows/build-and-deploy.yml @@ -0,0 +1,72 @@ +name: Build and Deploy + +on: + release: + types: [published] + +env: + APP_NAME: delta-force-guide-server + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 (Corretto) + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: corretto + cache: gradle + + - name: Build with Gradle + run: > + ./gradlew bootJar -x test + -PartefactVersion="${{ gitea.event.release.tag_name }}" + -PbuildChannel=stable + + - name: Resolve JAR file path + id: jar + run: | + JAR_PATH=$(find build/libs -name '*.jar' | head -1) + echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT" + + - name: Upload JAR to Gitea Release + run: | + TAG="${{ gitea.event.release.tag_name }}" + FILE="${{ steps.jar.outputs.file }}" + ASSET_NAME="${APP_NAME}-${TAG}.jar" + curl -X POST \ + -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ + -H "Content-Type: multipart/form-data" \ + -F "attachment=@${FILE};filename=${ASSET_NAME}" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${{ gitea.event.release.id }}/assets?name=${ASSET_NAME}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Generate image tags + id: meta + run: | + DOCKERHUB_USER="${{ secrets.DOCKER_HUB_USERNAME }}" + REPO_NAME=$(echo "${{ gitea.repository.name }}" | tr '[:upper:]' '[:lower:]') + echo "tag_version=${DOCKERHUB_USER}/${REPO_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + echo "tag_latest=${DOCKERHUB_USER}/${REPO_NAME}:latest" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.ci + build-args: JAR_FILE=${{ steps.jar.outputs.file }} + push: true + tags: | + ${{ steps.meta.outputs.tag_version }} + ${{ steps.meta.outputs.tag_latest }} diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml deleted file mode 100644 index 116f12f..0000000 --- a/.github/workflows/build-and-deploy.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Build and Deploy - -on: - release: - types: [published] - -env: - APP_NAME: delta-force-guide-server - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - # ================================================================ - # Single Job: Build, Upload JAR to Release, and Push to GHCR - # ================================================================ - build-and-release: - runs-on: ubuntu-latest - permissions: - contents: write - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 21 (Corretto) - uses: actions/setup-java@v4 - with: - java-version: 21 - distribution: corretto - cache: gradle - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - - # 使用 Release Tag 做为 Gradle 属性传入 - - name: Build with Gradle - run: ./gradlew bootJar -x test -PartefactVersion="${{ github.event.release.tag_name }}" - - - name: Resolve JAR file path - id: jar - run: | - JAR_PATH=$(find build/libs -name '*.jar' | head -1) - echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT" - - # 上传 JAR 包到 GitHub Release 中 - - name: Upload JAR to GitHub Release - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.jar.outputs.file }} - asset_name: ${{ github.event.repository.name }}-${{ github.event.release.tag_name }}.jar - tag: ${{ github.event.release.tag_name }} - overwrite: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - # 登录到 GitHub Container Registry (GHCR) - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # 镜像打标签准备 - - name: Generate image tags - id: meta - run: | - OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - REPO_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') - echo "tag_version=ghcr.io/$OWNER_LC/$REPO_LC:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" - echo "tag_latest=ghcr.io/$OWNER_LC/$REPO_LC:latest" >> "$GITHUB_OUTPUT" - - # 构建并上传镜像到 GHCR - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile.ci - build-args: JAR_FILE=${{ steps.jar.outputs.file }} - push: true - tags: | - ${{ steps.meta.outputs.tag_version }} - ${{ steps.meta.outputs.tag_latest }} - cache-from: type=gha - cache-to: type=gha,mode=max - From f8c0e3e8b6439e410033f1448dd38dd22c5072b9 Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 5 Jun 2026 08:58:55 +0800 Subject: [PATCH 087/117] ci: fix build error --- .gitea/workflows/build-and-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 1d83a90..4c04e5b 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -25,6 +25,7 @@ jobs: ./gradlew bootJar -x test -PartefactVersion="${{ gitea.event.release.tag_name }}" -PbuildChannel=stable + -Pvendor=${{ vars.VENDOR }} - name: Resolve JAR file path id: jar From ceac8a3f8c233dc3395169cab4bd3856fc9d2d95 Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 5 Jun 2026 09:14:37 +0800 Subject: [PATCH 088/117] ci: fix build error --- .gitea/workflows/build-and-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 4c04e5b..a2de098 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -51,7 +51,7 @@ jobs: uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_TOKEN }} + password: ${{ vars.DOCKER_HUB_TOKEN }} - name: Generate image tags id: meta From fb2732508a165b19e0d04c4e58b96d4b8b6b37bd Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 5 Jun 2026 09:30:28 +0800 Subject: [PATCH 089/117] ci: fix build error --- .gitea/workflows/build-and-deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index a2de098..b63a7c3 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -50,8 +50,8 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ vars.DOCKER_HUB_TOKEN }} + username: ${{ vars.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Generate image tags id: meta From c18a108a2b1f39855794e831f12e3a1a40dc4462 Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 5 Jun 2026 09:40:12 +0800 Subject: [PATCH 090/117] ci: fix build error --- .gitea/workflows/build-and-deploy.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index b63a7c3..9a9c559 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -56,10 +56,9 @@ jobs: - name: Generate image tags id: meta run: | - DOCKERHUB_USER="${{ secrets.DOCKER_HUB_USERNAME }}" - REPO_NAME=$(echo "${{ gitea.repository.name }}" | tr '[:upper:]' '[:lower:]') - echo "tag_version=${DOCKERHUB_USER}/${REPO_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT" - echo "tag_latest=${DOCKERHUB_USER}/${REPO_NAME}:latest" >> "$GITHUB_OUTPUT" + DOCKERHUB_USER="${{ vars.DOCKER_HUB_USERNAME }}" + echo "tag_version=${DOCKERHUB_USER}/${APP_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + echo "tag_latest=${DOCKERHUB_USER}/${APP_NAME}:latest" >> "$GITHUB_OUTPUT" - name: Build and push Docker image uses: docker/build-push-action@v6 From 62fcce3005bd6a32b4dc0ae3a2f64d020f418c8b Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 5 Jun 2026 10:20:15 +0800 Subject: [PATCH 091/117] feat: add OCI labels and README push for Docker Hub Add OCI standard labels to Dockerfile.ci and push README.md to Docker Hub image page via docker-pushrm in CI workflow. --- .gitea/workflows/build-and-deploy.yml | 15 +++++++++++- .gitlab-ci.yml | 34 --------------------------- Dockerfile.ci | 12 ++++++++++ 3 files changed, 26 insertions(+), 35 deletions(-) delete mode 100644 .gitlab-ci.yml diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 9a9c559..20220dc 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -65,8 +65,21 @@ jobs: with: context: . file: Dockerfile.ci - build-args: JAR_FILE=${{ steps.jar.outputs.file }} + build-args: | + JAR_FILE=${{ steps.jar.outputs.file }} + IMAGE_VERSION=${{ gitea.event.release.tag_name }} + IMAGE_VENDOR=${{ vars.VENDOR }} + BUILD_DATE=${{ gitea.event.release.published_at }} push: true tags: | ${{ steps.meta.outputs.tag_version }} ${{ steps.meta.outputs.tag_latest }} + + - name: Push README to Docker Hub + run: > + docker run --rm + -v "$(pwd)/README.md:/workspace/README.md:ro" + -e DOCKER_USER="${{ vars.DOCKER_HUB_USERNAME }}" + -e DOCKER_PASS="${{ secrets.DOCKER_HUB_TOKEN }}" + chko/docker-pushrm:latest + "${{ vars.DOCKER_HUB_USERNAME }}/${{ env.APP_NAME }}" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 618e6fb..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,34 +0,0 @@ -variables: - GRADLE_OPTS: -Dorg.gradle.daemon=false - DOCKER_HOST: unix:///var/run/docker.sock - -stages: - - release - -release: - stage: release - image: amazoncorretto:21-alpine - cache: - key: gradle - paths: - - .gradle/wrapper - - .gradle/caches - before_script: - - chmod +x gradlew - - apk add --no-cache docker-cli - script: - - ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG" - - JAR_FILE=$(find build/libs -name '*.jar' | head -1) - - echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE" - - docker build - -f Dockerfile.ci - --build-arg JAR_FILE="$JAR_FILE" - -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" - . - - docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest" - - echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" - - docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" - - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" - - docker push "$CI_REGISTRY_IMAGE:latest" - rules: - - if: $CI_COMMIT_TAG diff --git a/Dockerfile.ci b/Dockerfile.ci index 5b0e97c..7925330 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -2,6 +2,18 @@ FROM amazoncorretto:21-alpine WORKDIR /app ARG JAR_FILE +ARG IMAGE_VERSION=dev +ARG IMAGE_VENDOR=OnixByte +ARG BUILD_DATE + COPY ${JAR_FILE} app.jar +LABEL org.opencontainers.image.title="delta-force-guide-server" \ + org.opencontainers.image.description="REST API backend for managing Delta Force game firearm builds and modifications" \ + org.opencontainers.image.version="${IMAGE_VERSION}" \ + org.opencontainers.image.vendor="${IMAGE_VENDOR}" \ + org.opencontainers.image.source="https://git.onixbyte.com/onixbyte/delta-force-guide-server" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.created="${BUILD_DATE}" + ENTRYPOINT ["java", "-jar", "app.jar"] From a2469c7573426dad9fb4ea076008ab72a55c75ed Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 8 Jun 2026 13:32:16 +0800 Subject: [PATCH 092/117] docs: update application configuration example --- config/application-prod.yaml.example | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/config/application-prod.yaml.example b/config/application-prod.yaml.example index ae00f78..e68c145 100644 --- a/config/application-prod.yaml.example +++ b/config/application-prod.yaml.example @@ -9,15 +9,19 @@ spring: host: localhost port: 6379 database: 0 - # password: 6hLFVqfGPviTYukn # Uncomment if password is necessary + # password: qwerty # Uncomment if password is necessary logging: pattern: # dateformat: dd MMM yyyy HH:mm:ss.SSS # Modify this for custom date format. app: - common: - version: 1.3.0.8-dev # Application version, you can change to any version you like, used for communication with frontend. + cookie: + http-only: true + secure: false + same-site: none + path: '/' + max-age: P1D cors: allowed-origins: # Cross-origin allowed origins - "http://localhost:5173" # Dev server for vite. @@ -41,12 +45,10 @@ app: issuer: dfguide.local # Issuer host secret: qwertyuiopasdfghjklzxcvbnm123456 # JWT singing secret, a 32-byte long or longer string is recommended valid-time: PT2H # JWT valid duration - cookie: # Cookie settings. - http-only: true - secure: false - same-site: lax - path: '/' - max-age: PT2H + webhook: + github: + secret: 123456 + allowed-users: [ octotcat, onixbyte ] springdoc: api-docs: From 3f2066f8b232022a64781656a743b54d9aa15e9d Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 8 Jun 2026 14:14:05 +0800 Subject: [PATCH 093/117] fix: update docker-pushrm README mount path from /workspace to /data docker-pushrm v2 changed the WORKDIR from /workspace to /data, causing the README push step to fail when using the :latest tag. --- .gitea/workflows/build-and-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 20220dc..22c736f 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -78,7 +78,7 @@ jobs: - name: Push README to Docker Hub run: > docker run --rm - -v "$(pwd)/README.md:/workspace/README.md:ro" + -v "$(pwd)/README.md:/data/README.md:ro" -e DOCKER_USER="${{ vars.DOCKER_HUB_USERNAME }}" -e DOCKER_PASS="${{ secrets.DOCKER_HUB_TOKEN }}" chko/docker-pushrm:latest From 0269683b68346fd3ebbf74ad260e72cce3715447 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 8 Jun 2026 14:46:19 +0800 Subject: [PATCH 094/117] fix: mount README.md to / not /data for docker-pushrm The docker-pushrm image (busybox-based) has no WORKDIR set, defaulting to /. FindReadmeFile() searches ./ relative to WORKDIR, so the file must be at /README.md, not /data/ or /workspace/. --- .gitea/workflows/build-and-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 22c736f..a1e400c 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -78,7 +78,7 @@ jobs: - name: Push README to Docker Hub run: > docker run --rm - -v "$(pwd)/README.md:/data/README.md:ro" + -v "$(pwd)/README.md:/README.md:ro" -e DOCKER_USER="${{ vars.DOCKER_HUB_USERNAME }}" -e DOCKER_PASS="${{ secrets.DOCKER_HUB_TOKEN }}" chko/docker-pushrm:latest From 2b099a6b2cce061fbe8566636d0f4f452a376ea7 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 8 Jun 2026 14:56:54 +0800 Subject: [PATCH 095/117] fix: use update-container-description-action instead of raw docker-pushrm The raw docker run with docker-pushrm keeps failing due to WORKDIR / mount path mismatches. Replace with the official GitHub Action wrapper from the same author, which handles the internals correctly. --- .gitea/workflows/build-and-deploy.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index a1e400c..caed485 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -76,10 +76,11 @@ jobs: ${{ steps.meta.outputs.tag_latest }} - name: Push README to Docker Hub - run: > - docker run --rm - -v "$(pwd)/README.md:/README.md:ro" - -e DOCKER_USER="${{ vars.DOCKER_HUB_USERNAME }}" - -e DOCKER_PASS="${{ secrets.DOCKER_HUB_TOKEN }}" - chko/docker-pushrm:latest - "${{ vars.DOCKER_HUB_USERNAME }}/${{ env.APP_NAME }}" + uses: christian-korneck/update-container-description-action@v1 + env: + DOCKER_USER: ${{ vars.DOCKER_HUB_USERNAME }} + DOCKER_PASS: ${{ secrets.DOCKER_HUB_TOKEN }} + with: + destination_container_repo: ${{ vars.DOCKER_HUB_USERNAME }}/${{ env.APP_NAME }} + provider: dockerhub + readme_file: README.md From 0aeaac17a4ad7bd6a1bd91b78f30ceced320bec7 Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 29 Jun 2026 09:38:12 +0800 Subject: [PATCH 096/117] feat: custom 401/403 error handling for unauthenticated requests Replace default Spring Security 403 responses with custom 401 (not logged in) and 403 (insufficient permissions) JSON error responses via exception handling and global exception handler. Also bump API version to 1.4.0 and add Swagger annotations for the GitHub webhook controller. --- build.gradle.kts | 1 + .../config/OpenApiConfiguration.java | 4 ++-- .../config/SecurityConfig.java | 16 ++++++++++++++ .../controller/GitHubWebhookController.java | 4 ++++ .../controller/GlobalExceptionHandler.java | 21 +++++++++++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a98947a..8e62389 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { tasks.processResources { filesMatching("application.yaml") { + println("appVersion = ${artefactVersion}, channel = ${buildChannel}, vendor = ${vendor}") expand( "appVersion" to artefactVersion, "channel" to buildChannel, diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java b/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java index 9f47fe1..e86faaf 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java @@ -10,14 +10,14 @@ import org.springframework.context.annotation.Configuration; info = @Info( title = "Delta Force Guide Server", description = "API for managing Delta Force game firearm builds", - version = "1.3.4", + version = "1.4.0", contact = @Contact( name = "Zihlu Wang", email = "zihlu.wang@onixbyte.com" ), license = @License( name = "MIT", - url = "https://git.onixbyte.cn/onixbyte/delta-force-guide-server/-/raw/main/LICENCE" + url = "https://onixbyte.dev/onixbyte/delta-force-guide-server/raw/branch/main/LICENCE" ) ) ) diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index b32a691..7d75f6a 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -7,6 +7,8 @@ import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; import com.onixbyte.deltaforceguide.properties.CookieProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties; import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,6 +21,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import com.onixbyte.deltaforceguide.exeption.BizException; +import org.springframework.http.HttpStatus; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.web.cors.CorsConfigurationSource; @@ -34,6 +38,8 @@ import org.springframework.web.cors.CorsConfigurationSource; @EnableConfigurationProperties({TokenProperties.class, CookieProperties.class}) public class SecurityConfig { + private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); + /** * Configures the HTTP security filter chain including endpoint authorisation and JWT filter. * @@ -55,6 +61,16 @@ public class SecurityConfig { .authorizeHttpRequests((customiser) -> customiser .anyRequest().permitAll() ) + .exceptionHandling(customiser -> customiser + .authenticationEntryPoint((request, response, authException) -> { + log.error("Unauthenticated request: {}", request, authException); + throw new BizException(HttpStatus.UNAUTHORIZED, "请先登录"); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + log.error("Denied request: {}", request, accessDeniedException); + throw new BizException(HttpStatus.FORBIDDEN, "权限不足"); + }) + ) .addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class) .build(); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java index 580e1f9..8b6f3b6 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java @@ -3,6 +3,8 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; import com.onixbyte.deltaforceguide.service.WebhookService; import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; @@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "GitHub WebHook") @RestController @RequestMapping("/webhooks/github") public class GitHubWebhookController { @@ -24,6 +27,7 @@ public class GitHubWebhookController { this.webhookService = webhookService; } + @Operation(description = "GitHub WebHook 接口") @PostMapping public ResponseEntity handleWebhook( @RequestHeader(GitHubWebhookHeader.EVENT) String event, diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java index 4edd154..fc20ba4 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java @@ -2,7 +2,11 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.ErrorResponse; import com.onixbyte.deltaforceguide.exeption.BizException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -20,5 +24,22 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status) .body(new ErrorResponse(exception.getMessage())); } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException exception) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("请先登录")); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException(AccessDeniedException exception) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + var httpStatus = authentication == null || !authentication.isAuthenticated() + ? HttpStatus.UNAUTHORIZED + : HttpStatus.FORBIDDEN; + var message = httpStatus == HttpStatus.UNAUTHORIZED ? "请先登录" : "权限不足"; + return ResponseEntity.status(httpStatus) + .body(new ErrorResponse(message)); + } } From db5056e8d57f2ecdce6d56da1fec69a896516450 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 21 Jul 2026 13:43:40 +0800 Subject: [PATCH 097/117] feat(auth): add user profile centre and admin role system - add GET/PUT /user/me endpoints for profile viewing and editing - add PUT /user/me/password endpoint for password changes - add GET /user/me/builds skeleton endpoint for future CustomModification - add user_role table with ROLE_USER, ROLE_ADMIN, and ROLE_SUPER_USER roles - add @RequiresRole annotation with custom AuthorizationManager enforcement - extend User entity with nickname, avatarUrl, emailVerified, enabled, createdAt, and updatedAt columns - load user roles into SecurityContext authorities via TokenAuthenticationFilter - add @CurrentUser annotation and argument resolver for controller injection - add PUT/DELETE /admin/users/{id}/roles for SUPER_USER role management - add V6 and V7 Flyway migrations for schema changes Closes #5 Closes #8 --- .../deltaforceguide/config/AppConfig.java | 15 ++- .../config/SecurityConfig.java | 25 ++++ .../controller/AdminController.java | 60 +++++++++ .../controller/UserController.java | 79 +++++++++++ .../domain/dto/BuildSummaryResponse.java | 20 +++ .../domain/dto/ChangePasswordRequest.java | 23 ++++ .../domain/dto/ManageRoleRequest.java | 13 ++ .../domain/dto/UpdateProfileRequest.java | 18 +++ .../domain/dto/UserProfileResponse.java | 46 +++++++ .../deltaforceguide/domain/entity/User.java | 116 ++++++++++++++++ .../domain/entity/UserRole.java | 125 +++++++++++++++++ .../domain/entity/UserRoleId.java | 79 +++++++++++ .../filter/TokenAuthenticationFilter.java | 12 +- .../manager/UserRoleManager.java | 72 ++++++++++ .../repository/UserRoleRepository.java | 76 +++++++++++ .../security/annotation/RequiresRole.java | 24 ++++ .../UsernamePasswordAuthentication.java | 24 +++- .../RoleBasedAuthorizationManager.java | 57 ++++++++ .../security/resolver/CurrentUser.java | 16 +++ .../resolver/CurrentUserArgumentResolver.java | 44 ++++++ .../deltaforceguide/service/UserService.java | 126 +++++++++++++++++- .../onixbyte/deltaforceguide/shared/Role.java | 15 +++ .../db/migration/V6__user_profile.sql | 7 + .../resources/db/migration/V7__user_role.sql | 6 + 24 files changed, 1091 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/controller/UserController.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/BuildSummaryResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/ChangePasswordRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/ManageRoleRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/manager/UserRoleManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/shared/Role.java create mode 100644 src/main/resources/db/migration/V6__user_profile.sql create mode 100644 src/main/resources/db/migration/V7__user_role.sql diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index 6bba2db..6ec3294 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -3,12 +3,16 @@ package com.onixbyte.deltaforceguide.config; import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor; import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; +import com.onixbyte.deltaforceguide.security.resolver.CurrentUserArgumentResolver; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + @Configuration @EnableConfigurationProperties(AppProperties.class) public class AppConfig implements WebMvcConfigurer { @@ -17,13 +21,17 @@ public class AppConfig implements WebMvcConfigurer { private final GitHubWebhookInterceptor gitHubWebhookInterceptor; + private final CurrentUserArgumentResolver currentUserArgumentResolver; + @Autowired public AppConfig( TrafficInterceptor trafficInterceptor, - GitHubWebhookInterceptor gitHubWebhookInterceptor + GitHubWebhookInterceptor gitHubWebhookInterceptor, + CurrentUserArgumentResolver currentUserArgumentResolver ) { this.trafficInterceptor = trafficInterceptor; this.gitHubWebhookInterceptor = gitHubWebhookInterceptor; + this.currentUserArgumentResolver = currentUserArgumentResolver; } @Override @@ -32,4 +40,9 @@ public class AppConfig implements WebMvcConfigurer { registry.addInterceptor(gitHubWebhookInterceptor) .addPathPatterns("/webhooks/github"); } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index 7d75f6a..fc0b81d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -6,14 +6,22 @@ import com.auth0.jwt.algorithms.Algorithm; import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; import com.onixbyte.deltaforceguide.properties.CookieProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties; +import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; +import com.onixbyte.deltaforceguide.security.authorisation.RoleBasedAuthorizationManager; import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -109,4 +117,21 @@ public class SecurityConfig { .withIssuer(tokenProperties.issuer()) .build(); } + + /** + * Registers the {@link RequiresRole} annotation as a method-security interceptor. + * Methods and classes annotated with {@code @RequiresRole} are checked by the + * {@link RoleBasedAuthorizationManager}. + * + * @param manager the role-based authorisation manager + * @return the advisor that intercepts {@code @RequiresRole}-annotated methods + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor requiresRoleAdvisor(RoleBasedAuthorizationManager manager) { + var pointcut = new AnnotationMatchingPointcut(null, RequiresRole.class, true); + var interceptor = new AuthorizationManagerBeforeMethodInterceptor(pointcut, manager); + interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder() + 10); + return interceptor; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java new file mode 100644 index 0000000..fd65341 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java @@ -0,0 +1,60 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.ManageRoleRequest; +import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; +import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; +import com.onixbyte.deltaforceguide.service.UserService; +import com.onixbyte.deltaforceguide.shared.Role; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; + +/** + * REST controller for administrative user and role management. + * + * @author zihluwang + */ +@Tag(name = "管理员", description = "管理员用户与角色管理") +@RestController +@RequestMapping("/admin") +@Validated +@RequiresAuth +public class AdminController { + + private final UserService userService; + + public AdminController(UserService userService) { + this.userService = userService; + } + + @Operation(description = "为用户分配角色(仅超级管理员可操作)") + @RequiresRole(Role.ROLE_SUPER_USER) + @PutMapping("/users/{id}/roles") + public ResponseEntity assignRole( + @PathVariable @Positive(message = "用户ID必须为正数") Long id, + @Valid @RequestBody ManageRoleRequest request + ) { + userService.assignRole(id, request.role()); + return ResponseEntity.noContent().build(); + } + + @Operation(description = "移除用户角色(仅超级管理员可操作)") + @RequiresRole(Role.ROLE_SUPER_USER) + @DeleteMapping("/users/{id}/roles/{role}") + public ResponseEntity removeRole( + @PathVariable @Positive(message = "用户ID必须为正数") Long id, + @PathVariable String role + ) { + userService.removeRole(id, role); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/UserController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/UserController.java new file mode 100644 index 0000000..9678eb6 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/UserController.java @@ -0,0 +1,79 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.BuildSummaryResponse; +import com.onixbyte.deltaforceguide.domain.dto.ChangePasswordRequest; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.dto.UpdateProfileRequest; +import com.onixbyte.deltaforceguide.domain.dto.UserProfileResponse; +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; +import com.onixbyte.deltaforceguide.security.resolver.CurrentUser; +import com.onixbyte.deltaforceguide.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +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; + +/** + * REST controller for the current user's profile management endpoints. + * + * @author zihluwang + */ +@Tag(name = "用户个人中心", description = "当前用户的资料管理与密码修改") +@RestController +@RequestMapping("/user") +@Validated +@RequiresAuth +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @Operation(description = "获取当前登录用户的个人资料") + @GetMapping("/me") + public UserProfileResponse profile(@CurrentUser User user) { + return userService.getProfile(user); + } + + @Operation(description = "修改当前用户的个人资料(昵称与邮箱)") + @PutMapping("/me") + public UserProfileResponse updateProfile( + @CurrentUser User user, + @Valid @RequestBody UpdateProfileRequest request + ) { + return userService.updateProfile(user, request); + } + + @Operation(description = "修改当前用户的登录密码") + @PutMapping("/me/password") + public ResponseEntity changePassword( + @CurrentUser User user, + @Valid @RequestBody ChangePasswordRequest request + ) { + userService.changePassword(user, request); + return ResponseEntity.noContent().build(); + } + + @Operation(description = "分页查询当前用户的改枪码列表") + @GetMapping("/me/builds") + public PageResponse builds( + @CurrentUser User user, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return userService.getBuilds(user, PageRequest.of(page, size)); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/BuildSummaryResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/BuildSummaryResponse.java new file mode 100644 index 0000000..e900bac --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/BuildSummaryResponse.java @@ -0,0 +1,20 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import java.util.List; + +/** + * Response record summarising a user's custom modification build. + * This is a skeleton record whose {@code from()} factory will be fully implemented + * when the CustomModification entity is introduced in a future issue. + * + * @author zihluwang + */ +public record BuildSummaryResponse( + Long id, + String name, + String code, + String firearmName, + List tags +) { + // from() factory to be implemented when CustomModification entity lands +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ChangePasswordRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..1cd829c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ChangePasswordRequest.java @@ -0,0 +1,23 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Request record for changing the current user's password. + * + * @author zihluwang + */ +public record ChangePasswordRequest( + @NotBlank(message = "旧密码不能为空") + String oldPassword, + + @NotBlank(message = "新密码不能为空") + @Size(min = 8, max = 128, message = "密码长度必须在8-128个字符之间") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = "密码必须包含至少一个大写字母、一个小写字母和一个数字" + ) + String newPassword +) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ManageRoleRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ManageRoleRequest.java new file mode 100644 index 0000000..72ba68e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ManageRoleRequest.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * Request record for managing a user's role assignment. + * + * @author zihluwang + */ +public record ManageRoleRequest( + @NotBlank(message = "角色不能为空") + String role +) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java new file mode 100644 index 0000000..0d1b016 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java @@ -0,0 +1,18 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +/** + * Request record for updating the current user's profile information. + * + * @author zihluwang + */ +public record UpdateProfileRequest( + @Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间") + String nickname, + + @Email(message = "请输入正确的电子邮箱地址") + @Size(max = 255, message = "电子邮箱长度不能超过255个字符") + String email +) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java new file mode 100644 index 0000000..5609119 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java @@ -0,0 +1,46 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.User; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Response record for the current user's full profile information. + * + * @author zihluwang + */ +public record UserProfileResponse( + Long id, + String username, + String nickname, + String email, + String avatarUrl, + Boolean emailVerified, + Boolean enabled, + LocalDateTime createdAt, + List roles +) { + + /** + * Creates a {@code UserProfileResponse} from a {@link User} entity and its assigned roles. + * + * @param user the user entity + * @param roles the list of role names assigned to the user + * @return the constructed response + */ + public static UserProfileResponse from(User user, List roles) { + return new UserProfileResponse( + user.getId(), + user.getUsername(), + user.getNickname() == null || user.getNickname().isBlank() + ? user.getUsername() : user.getNickname(), + user.getEmail(), + user.getAvatarUrl(), + user.getEmailVerified(), + user.getEnabled(), + user.getCreatedAt(), + roles + ); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java index d696b2d..c061c09 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -7,8 +7,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -31,6 +33,24 @@ public class User { @Column(name = "email", nullable = false) private String email; + @Column(name = "nickname") + private String nickname; + + @Column(name = "avatar_url") + private String avatarUrl; + + @Column(name = "email_verified", nullable = false) + private Boolean emailVerified = false; + + @Column(name = "enabled", nullable = false) + private Boolean enabled = true; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List credentials = new ArrayList<>(); @@ -58,6 +78,59 @@ public class User { this.email = email; } + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public Boolean getEmailVerified() { + return emailVerified; + } + + public void setEmailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + @PreUpdate + private void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + public List getCredentials() { return credentials; } @@ -85,6 +158,12 @@ public class User { private Long id; private String username; private String email; + private String nickname; + private String avatarUrl; + private Boolean emailVerified; + private Boolean enabled; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; private List credentials; public Builder id(Long id) { @@ -102,6 +181,36 @@ public class User { return this; } + public Builder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public Builder avatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + return this; + } + + public Builder emailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + return this; + } + + public Builder enabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + public Builder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public Builder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + public Builder credentials(List credentials) { this.credentials = credentials; return this; @@ -112,6 +221,13 @@ public class User { user.id = this.id; user.username = this.username; user.email = this.email; + user.nickname = this.nickname == null || this.nickname.isBlank() + ? this.username : this.nickname; + user.avatarUrl = this.avatarUrl; + user.emailVerified = this.emailVerified != null && this.emailVerified; + user.enabled = this.enabled == null || this.enabled; + user.createdAt = this.createdAt; + user.updatedAt = this.updatedAt; user.credentials = this.credentials == null ? new ArrayList<>() : this.credentials; return user; } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java new file mode 100644 index 0000000..8caf84e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java @@ -0,0 +1,125 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; + +/** + * Entity representing a role assignment linking a user to a security role. + * + * @author zihluwang + */ +@Entity +@Table(name = "user_role") +public class UserRole { + + @EmbeddedId + @AttributeOverrides({ + @AttributeOverride(name = "userId", column = @Column(name = "user_id")), + @AttributeOverride(name = "role", column = @Column(name = "role")) + }) + private UserRoleId id = new UserRoleId(); + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_role_user")) + private User user; + + public UserRoleId getId() { + return id; + } + + public void setId(UserRoleId id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + if (this.id == null) { + this.id = new UserRoleId(); + } + this.id.setUserId(user == null ? null : user.getId()); + } + + public Long getUserId() { + return id == null ? null : id.getUserId(); + } + + public void setUserId(Long userId) { + if (this.id == null) { + this.id = new UserRoleId(); + } + this.id.setUserId(userId); + } + + public String getRole() { + return id == null ? null : id.getRole(); + } + + public void setRole(String role) { + if (this.id == null) { + this.id = new UserRoleId(); + } + this.id.setRole(role); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private UserRoleId id; + private User user; + private Long userId; + private String role; + + public Builder id(UserRoleId id) { + this.id = id; + return this; + } + + public Builder user(User user) { + this.user = user; + return this; + } + + public Builder userId(Long userId) { + this.userId = userId; + return this; + } + + public Builder role(String role) { + this.role = role; + return this; + } + + public UserRole build() { + UserRole userRole = new UserRole(); + userRole.id = this.id == null ? new UserRoleId() : this.id; + userRole.user = this.user; + if (this.user != null) { + userRole.id.setUserId(this.user.getId()); + } + if (this.userId != null) { + userRole.id.setUserId(this.userId); + } + if (this.role != null) { + userRole.id.setRole(this.role); + } + return userRole; + } + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java new file mode 100644 index 0000000..96f5b0e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java @@ -0,0 +1,79 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.Embeddable; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Composite key for the UserRole entity, combining user ID and role name. + * + * @author zihluwang + */ +@Embeddable +public class UserRoleId implements Serializable { + + private Long userId; + + private String role; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserRoleId that = (UserRoleId) o; + return Objects.equals(userId, that.userId) && Objects.equals(role, that.role); + } + + @Override + public int hashCode() { + return Objects.hash(userId, role); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Long userId; + private String role; + + public Builder userId(Long userId) { + this.userId = userId; + return this; + } + + public Builder role(String role) { + this.role = role; + return this; + } + + public UserRoleId build() { + UserRoleId id = new UserRoleId(); + id.userId = this.userId; + id.role = this.role; + return id; + } + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index e2068a6..1a690e1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -4,6 +4,7 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.manager.UserManager; +import com.onixbyte.deltaforceguide.manager.UserRoleManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; import com.onixbyte.deltaforceguide.service.CookieService; import jakarta.servlet.FilterChain; @@ -16,6 +17,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -27,6 +29,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; /** * Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request. @@ -40,17 +43,20 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { private static final Duration ACCESS_TOKEN_RENEW_THRESHOLD = Duration.ofMinutes(5); private final UserManager userManager; + private final UserRoleManager userRoleManager; private final TokenClient tokenClient; private final CookieService cookieService; private final HandlerExceptionResolver handlerExceptionResolver; public TokenAuthenticationFilter( UserManager userManager, + UserRoleManager userRoleManager, TokenClient tokenClient, CookieService cookieService, HandlerExceptionResolver handlerExceptionResolver ) { this.userManager = userManager; + this.userRoleManager = userRoleManager; this.tokenClient = tokenClient; this.cookieService = cookieService; this.handlerExceptionResolver = handlerExceptionResolver; @@ -87,7 +93,11 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { } var user = userWrapper.get(); - var authentication = UsernamePasswordAuthentication.authenticated(user); + var roles = userRoleManager.findAllByUserId(user.getId()); + var authorities = roles.stream() + .map(r -> new SimpleGrantedAuthority(r.getRole())) + .collect(Collectors.toList()); + var authentication = UsernamePasswordAuthentication.authenticated(user, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); if (shouldRenew(decodedToken.getExpiresAt().toInstant())) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserRoleManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserRoleManager.java new file mode 100644 index 0000000..4a2058c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserRoleManager.java @@ -0,0 +1,72 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.domain.entity.UserRole; +import com.onixbyte.deltaforceguide.repository.UserRoleRepository; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Manager for user role persistence and authorisation data access. + * + * @author zihluwang + */ +@Component +public class UserRoleManager { + + private final UserRoleRepository userRoleRepository; + + public UserRoleManager(UserRoleRepository userRoleRepository) { + this.userRoleRepository = userRoleRepository; + } + + /** + * Finds all role assignments for a specific user. + * + * @param userId the user ID + * @return list of matching role assignments + */ + public List findAllByUserId(Long userId) { + return userRoleRepository.findAllByUserId(userId); + } + + /** + * Checks whether a user has a specific role. + * + * @param userId the user ID + * @param role the role name + * @return true if the role is assigned, false otherwise + */ + public boolean existsByUserIdAndRole(Long userId, String role) { + return userRoleRepository.existsByUserIdAndRole(userId, role); + } + + /** + * Persists a new role assignment. + * + * @param userRole the role assignment to save + * @return the saved role assignment + */ + public UserRole save(UserRole userRole) { + return userRoleRepository.save(userRole); + } + + /** + * Deletes a role assignment for a specific user and role. + * + * @param userId the user ID + * @param role the role name + */ + public void deleteByUserIdAndRole(Long userId, String role) { + userRoleRepository.deleteByUserIdAndRole(userId, role); + } + + /** + * Deletes all role assignments belonging to a user. + * + * @param userId the user ID + */ + public void deleteAllByUserId(Long userId) { + userRoleRepository.deleteAllByUserId(userId); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java new file mode 100644 index 0000000..f2ce1e8 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java @@ -0,0 +1,76 @@ +package com.onixbyte.deltaforceguide.repository; + +import com.onixbyte.deltaforceguide.domain.entity.UserRole; +import com.onixbyte.deltaforceguide.domain.entity.UserRoleId; +import org.springframework.data.jpa.repository.EntityGraph; +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.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA repository for {@link UserRole} entity operations. + * + * @author zihluwang + */ +@Repository +public interface UserRoleRepository extends JpaRepository { + + /** + * Find all roles assigned to a given user. + * + * @param userId the user ID + * @return list of matching role assignments + */ + @EntityGraph(attributePaths = {"user"}) + @Query(""" + select ur + from UserRole ur + where ur.user.id = :userId + """) + List findAllByUserId(@Param("userId") Long userId); + + /** + * Check whether a user has a specific role. + * + * @param userId the user ID + * @param role the role name + * @return true if the role is assigned, false otherwise + */ + @Query(""" + select count(ur) > 0 + from UserRole ur + where ur.user.id = :userId + and ur.id.role = :role + """) + boolean existsByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role); + + /** + * Delete a specific role assignment for a user. + * + * @param userId the user ID + * @param role the role name + */ + @Modifying + @Query(""" + delete from UserRole ur + where ur.user.id = :userId + and ur.id.role = :role + """) + void deleteByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role); + + /** + * Delete all role assignments for a given user. + * + * @param userId the user ID + */ + @Modifying + @Query(""" + delete from UserRole ur + where ur.user.id = :userId + """) + void deleteAllByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java new file mode 100644 index 0000000..ae3e71c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java @@ -0,0 +1,24 @@ +package com.onixbyte.deltaforceguide.security.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for protecting controller methods or classes with role-based access control. + * When applied, the current authenticated user must possess at least one of the specified roles. + * + * @author zihluwang + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresRole { + + /** + * The required role names. The user must possess at least one of these. + * + * @return array of role name strings + */ + String[] value(); +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java index 9bd2ae3..c6eb826 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java @@ -5,6 +5,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.GrantedAuthority; +import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -18,25 +19,40 @@ public class UsernamePasswordAuthentication implements Authentication, Credentia private String password; private boolean authenticated; private User user; + private Collection authorities; - private UsernamePasswordAuthentication(String username, String password, boolean authenticated, User user) { + private UsernamePasswordAuthentication( + String username, + String password, + boolean authenticated, + User user, + Collection authorities + ) { this.username = username; this.password = password; this.authenticated = authenticated; this.user = user; + this.authorities = authorities == null ? List.of() : new ArrayList<>(authorities); } public static UsernamePasswordAuthentication unauthenticated(String username, String password) { - return new UsernamePasswordAuthentication(username, password, false, null); + return new UsernamePasswordAuthentication(username, password, false, null, List.of()); } public static UsernamePasswordAuthentication authenticated(User user) { - return new UsernamePasswordAuthentication(user.getUsername(), null, true, user); + return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, List.of()); + } + + public static UsernamePasswordAuthentication authenticated( + User user, + Collection authorities + ) { + return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, authorities); } @Override public Collection getAuthorities() { - return List.of(); + return authorities; } @Override diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java b/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java new file mode 100644 index 0000000..90f95ee --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java @@ -0,0 +1,57 @@ +package com.onixbyte.deltaforceguide.security.authorisation; + +import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; +import org.aopalliance.intercept.MethodInvocation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.function.Supplier; + +/** + * Spring Security {@link AuthorizationManager} that enforces {@link RequiresRole} annotations. + * Checks that the authenticated user possesses at least one of the required roles. + * + * @author zihluwang + */ +@Component +public class RoleBasedAuthorizationManager implements AuthorizationManager { + + private static final Logger log = LoggerFactory.getLogger(RoleBasedAuthorizationManager.class); + + @Override + public AuthorizationDecision check( + Supplier authenticationSupplier, + MethodInvocation invocation + ) { + var auth = authenticationSupplier.get(); + if (auth == null || !auth.isAuthenticated()) { + log.debug("Access denied: no authenticated user"); + return new AuthorizationDecision(false); + } + + var annotation = invocation.getMethod().getAnnotation(RequiresRole.class); + if (annotation == null) { + annotation = invocation.getMethod().getDeclaringClass() + .getAnnotation(RequiresRole.class); + } + if (annotation == null) { + return new AuthorizationDecision(true); + } + + var requiredRoles = Set.of(annotation.value()); + var hasRole = auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(requiredRoles::contains); + + if (!hasRole) { + log.debug("Access denied: user lacks required roles {}", requiredRoles); + } + return new AuthorizationDecision(hasRole); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java new file mode 100644 index 0000000..5c66d3e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java @@ -0,0 +1,16 @@ +package com.onixbyte.deltaforceguide.security.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for injecting the currently authenticated {@code User} into controller method + * parameters. The resolver extracts the user from the {@code SecurityContext}. + * + * @author zihluwang + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUser {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..3ab241e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java @@ -0,0 +1,44 @@ +package com.onixbyte.deltaforceguide.security.resolver; + +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.server.ResponseStatusException; + +/** + * Resolves {@link CurrentUser}-annotated controller method parameters by extracting the + * {@link User} entity from the current {@link UsernamePasswordAuthentication} in the + * security context. + * + * @author zihluwang + */ +@Component +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && parameter.getParameterType().equals(User.class); + } + + @Override + public User resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof UsernamePasswordAuthentication upa && upa.isAuthenticated()) { + return upa.getDetails(); + } + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请先登录"); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java index 700e26d..0d4a433 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -1,14 +1,29 @@ package com.onixbyte.deltaforceguide.service; +import com.onixbyte.deltaforceguide.domain.dto.BuildSummaryResponse; +import com.onixbyte.deltaforceguide.domain.dto.ChangePasswordRequest; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.dto.UpdateProfileRequest; +import com.onixbyte.deltaforceguide.domain.dto.UserProfileResponse; import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.domain.entity.UserRole; +import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.manager.UserCredentialManager; import com.onixbyte.deltaforceguide.manager.UserManager; +import com.onixbyte.deltaforceguide.manager.UserRoleManager; +import com.onixbyte.deltaforceguide.shared.CredentialProvider; +import com.onixbyte.deltaforceguide.shared.Role; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Objects; /** * Service for user account management and profile operations. @@ -20,10 +35,19 @@ public class UserService { private final UserManager userManager; private final UserCredentialManager userCredentialManager; + private final UserRoleManager userRoleManager; + private final PasswordEncoder passwordEncoder; - public UserService(UserManager userManager, UserCredentialManager userCredentialManager) { + public UserService( + UserManager userManager, + UserCredentialManager userCredentialManager, + UserRoleManager userRoleManager, + PasswordEncoder passwordEncoder + ) { this.userManager = userManager; this.userCredentialManager = userCredentialManager; + this.userRoleManager = userRoleManager; + this.passwordEncoder = passwordEncoder; } /** @@ -150,6 +174,106 @@ public class UserService { return userManager.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + userId)); } + + /** + * Builds the full profile response for the given user, including their assigned roles. + * + * @param user the authenticated user + * @return the user's profile response + */ + public UserProfileResponse getProfile(User user) { + var roles = userRoleManager.findAllByUserId(user.getId()).stream() + .map(UserRole::getRole) + .toList(); + return UserProfileResponse.from(user, roles); + } + + /** + * Updates the current user's profile information (nickname and email). + * If the email address changes, the email verification status is reset. + * + * @param user the authenticated user + * @param request the update request containing new nickname and email + * @return the updated user profile response + */ + public UserProfileResponse updateProfile(User user, UpdateProfileRequest request) { + if (StringUtils.hasText(request.nickname())) { + user.setNickname(request.nickname()); + } + if (StringUtils.hasText(request.email()) && !Objects.equals(user.getEmail(), request.email())) { + user.setEmail(request.email()); + user.setEmailVerified(false); + } + userManager.save(user); + return getProfile(user); + } + + /** + * Changes the current user's password after validating the old password. + * + * @param user the authenticated user + * @param request the change password request containing old and new passwords + * @throws BizException if the old password is incorrect + */ + public void changePassword(User user, ChangePasswordRequest request) { + var credential = userCredentialManager.findByUserIdAndProvider(user.getId(), CredentialProvider.LOCAL); + if (credential.isEmpty() || !passwordEncoder.matches(request.oldPassword(), credential.get().getCredential())) { + throw new BizException(HttpStatus.BAD_REQUEST, "旧密码不正确。"); + } + var encoded = passwordEncoder.encode(request.newPassword()); + upsertCredential(user.getId(), CredentialProvider.LOCAL, encoded); + } + + /** + * Retrieves a paginated list of the current user's custom modification builds. + * This is a skeleton implementation that returns an empty page. + * It will be activated when the CustomModification entity is introduced. + * + * @param user the authenticated user + * @param pageable pagination parameters + * @return an empty paginated response + */ + public PageResponse getBuilds(User user, Pageable pageable) { + var emptyPage = Page.empty(pageable); + return PageResponse.from(emptyPage); + } + + /** + * Assigns a role to a user. Only {@code ROLE_ADMIN} may be assigned via this API. + * + * @param userId the target user ID + * @param role the role to assign + * @throws BizException if attempting to assign a protected role + */ + public void assignRole(Long userId, String role) { + if (!Role.ROLE_ADMIN.equals(role)) { + throw new BizException(HttpStatus.BAD_REQUEST, "只能通过API分配ROLE_ADMIN角色。"); + } + var user = ensureUserExists(userId); + if (userRoleManager.existsByUserIdAndRole(userId, role)) { + return; // already assigned — idempotent + } + var userRole = UserRole.builder() + .user(user) + .role(role) + .build(); + userRoleManager.save(userRole); + } + + /** + * Removes a role from a user. Only {@code ROLE_ADMIN} may be removed via this API. + * + * @param userId the target user ID + * @param role the role to remove + * @throws BizException if attempting to remove a protected role + */ + public void removeRole(Long userId, String role) { + if (!Role.ROLE_ADMIN.equals(role)) { + throw new BizException(HttpStatus.BAD_REQUEST, "只能通过API移除ROLE_ADMIN角色。"); + } + ensureUserExists(userId); + userRoleManager.deleteByUserIdAndRole(userId, role); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java b/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java new file mode 100644 index 0000000..719321e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java @@ -0,0 +1,15 @@ +package com.onixbyte.deltaforceguide.shared; + +/** + * Constants for application role names used in authorisation decisions. + * + * @author zihluwang + */ +public class Role { + + public static final String ROLE_USER = "ROLE_USER"; + public static final String ROLE_ADMIN = "ROLE_ADMIN"; + public static final String ROLE_SUPER_USER = "ROLE_SUPER_USER"; + + private Role() {} +} diff --git a/src/main/resources/db/migration/V6__user_profile.sql b/src/main/resources/db/migration/V6__user_profile.sql new file mode 100644 index 0000000..6bb1ff8 --- /dev/null +++ b/src/main/resources/db/migration/V6__user_profile.sql @@ -0,0 +1,7 @@ +ALTER TABLE app_user + ADD COLUMN nickname VARCHAR(64), + ADD COLUMN avatar_url VARCHAR(512), + ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); diff --git a/src/main/resources/db/migration/V7__user_role.sql b/src/main/resources/db/migration/V7__user_role.sql new file mode 100644 index 0000000..57a0049 --- /dev/null +++ b/src/main/resources/db/migration/V7__user_role.sql @@ -0,0 +1,6 @@ +CREATE TABLE user_role +( + user_id BIGINT NOT NULL REFERENCES app_user (id), + role VARCHAR(32) NOT NULL, + CONSTRAINT user_role_pkey PRIMARY KEY (user_id, role) +); From c1d893db5f74fc303ee0b334a69c54bc6ff48973 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 21 Jul 2026 14:21:13 +0800 Subject: [PATCH 098/117] refactor(auth): simplify role enforcement with built-in @PreAuthorize - remove custom RequiresRole annotation and RoleBasedAuthorizationManager - replace @RequiresRole with @PreAuthorize("hasRole('SUPER_USER')") in AdminController - remove requiresRoleAdvisor bean from SecurityConfig --- .../config/SecurityConfig.java | 27 +-------- .../controller/AdminController.java | 7 +-- .../security/annotation/RequiresRole.java | 24 -------- .../RoleBasedAuthorizationManager.java | 57 ------------------- 4 files changed, 4 insertions(+), 111 deletions(-) delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index fc0b81d..6158bd8 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -6,22 +6,15 @@ import com.auth0.jwt.algorithms.Algorithm; import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; import com.onixbyte.deltaforceguide.properties.CookieProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties; -import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; -import com.onixbyte.deltaforceguide.security.authorisation.RoleBasedAuthorizationManager; import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.aop.Advisor; -import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; -import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Role; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; -import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder; -import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -30,7 +23,6 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import com.onixbyte.deltaforceguide.exeption.BizException; -import org.springframework.http.HttpStatus; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.web.cors.CorsConfigurationSource; @@ -117,21 +109,4 @@ public class SecurityConfig { .withIssuer(tokenProperties.issuer()) .build(); } - - /** - * Registers the {@link RequiresRole} annotation as a method-security interceptor. - * Methods and classes annotated with {@code @RequiresRole} are checked by the - * {@link RoleBasedAuthorizationManager}. - * - * @param manager the role-based authorisation manager - * @return the advisor that intercepts {@code @RequiresRole}-annotated methods - */ - @Bean - @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static Advisor requiresRoleAdvisor(RoleBasedAuthorizationManager manager) { - var pointcut = new AnnotationMatchingPointcut(null, RequiresRole.class, true); - var interceptor = new AuthorizationManagerBeforeMethodInterceptor(pointcut, manager); - interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder() + 10); - return interceptor; - } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java index fd65341..a8fe338 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java @@ -2,14 +2,13 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.ManageRoleRequest; import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; -import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; import com.onixbyte.deltaforceguide.service.UserService; -import com.onixbyte.deltaforceguide.shared.Role; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -37,7 +36,7 @@ public class AdminController { } @Operation(description = "为用户分配角色(仅超级管理员可操作)") - @RequiresRole(Role.ROLE_SUPER_USER) + @PreAuthorize("hasRole('SUPER_USER')") @PutMapping("/users/{id}/roles") public ResponseEntity assignRole( @PathVariable @Positive(message = "用户ID必须为正数") Long id, @@ -48,7 +47,7 @@ public class AdminController { } @Operation(description = "移除用户角色(仅超级管理员可操作)") - @RequiresRole(Role.ROLE_SUPER_USER) + @PreAuthorize("hasRole('SUPER_USER')") @DeleteMapping("/users/{id}/roles/{role}") public ResponseEntity removeRole( @PathVariable @Positive(message = "用户ID必须为正数") Long id, diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java deleted file mode 100644 index ae3e71c..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.onixbyte.deltaforceguide.security.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Annotation for protecting controller methods or classes with role-based access control. - * When applied, the current authenticated user must possess at least one of the specified roles. - * - * @author zihluwang - */ -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface RequiresRole { - - /** - * The required role names. The user must possess at least one of these. - * - * @return array of role name strings - */ - String[] value(); -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java b/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java deleted file mode 100644 index 90f95ee..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.onixbyte.deltaforceguide.security.authorisation; - -import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; -import org.aopalliance.intercept.MethodInvocation; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.authorization.AuthorizationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.stereotype.Component; - -import java.util.Set; -import java.util.function.Supplier; - -/** - * Spring Security {@link AuthorizationManager} that enforces {@link RequiresRole} annotations. - * Checks that the authenticated user possesses at least one of the required roles. - * - * @author zihluwang - */ -@Component -public class RoleBasedAuthorizationManager implements AuthorizationManager { - - private static final Logger log = LoggerFactory.getLogger(RoleBasedAuthorizationManager.class); - - @Override - public AuthorizationDecision check( - Supplier authenticationSupplier, - MethodInvocation invocation - ) { - var auth = authenticationSupplier.get(); - if (auth == null || !auth.isAuthenticated()) { - log.debug("Access denied: no authenticated user"); - return new AuthorizationDecision(false); - } - - var annotation = invocation.getMethod().getAnnotation(RequiresRole.class); - if (annotation == null) { - annotation = invocation.getMethod().getDeclaringClass() - .getAnnotation(RequiresRole.class); - } - if (annotation == null) { - return new AuthorizationDecision(true); - } - - var requiredRoles = Set.of(annotation.value()); - var hasRole = auth.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .anyMatch(requiredRoles::contains); - - if (!hasRole) { - log.debug("Access denied: user lacks required roles {}", requiredRoles); - } - return new AuthorizationDecision(hasRole); - } -} From b131738dda63029b1798d6b32ab9fc646e523665 Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 6 Aug 2026 17:20:36 +0800 Subject: [PATCH 099/117] refactor: replace BizException with ResponseStatusException --- .../config/SecurityConfig.java | 11 ++--- .../controller/GlobalExceptionHandler.java | 11 +++-- .../exeption/BadRequestException.java | 11 +++++ .../exeption/BizException.java | 40 ------------------- .../exeption/ForbiddenException.java | 11 +++++ .../InternalServerErrorException.java | 11 +++++ .../exeption/NotFoundException.java | 11 +++++ .../exeption/UnauthorisedException.java | 11 +++++ .../filter/TokenAuthenticationFilter.java | 10 ++--- .../interceptor/GitHubWebhookInterceptor.java | 12 +++--- .../manager/DailyPasswordManager.java | 5 +-- ...sernamePasswordAuthenticationProvider.java | 12 +++--- .../deltaforceguide/service/AuthService.java | 6 +-- .../service/FirearmService.java | 6 +-- .../deltaforceguide/service/UserService.java | 17 +++----- .../service/WebhookService.java | 5 +-- 16 files changed, 97 insertions(+), 93 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/BadRequestException.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/ForbiddenException.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/InternalServerErrorException.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/NotFoundException.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/UnauthorisedException.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index 6158bd8..dd52daa 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -3,6 +3,8 @@ package com.onixbyte.deltaforceguide.config; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; +import com.onixbyte.deltaforceguide.exeption.ForbiddenException; +import com.onixbyte.deltaforceguide.exeption.UnauthorisedException; import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; import com.onixbyte.deltaforceguide.properties.CookieProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties; @@ -12,7 +14,6 @@ import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -22,7 +23,6 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import com.onixbyte.deltaforceguide.exeption.BizException; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.web.cors.CorsConfigurationSource; @@ -43,7 +43,7 @@ public class SecurityConfig { /** * Configures the HTTP security filter chain including endpoint authorisation and JWT filter. * - * @param http the HTTP security builder + * @param httpSecurity the HTTP security builder * @return the configured security filter chain */ @Bean @@ -64,11 +64,11 @@ public class SecurityConfig { .exceptionHandling(customiser -> customiser .authenticationEntryPoint((request, response, authException) -> { log.error("Unauthenticated request: {}", request, authException); - throw new BizException(HttpStatus.UNAUTHORIZED, "请先登录"); + throw new UnauthorisedException("请先登录"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { log.error("Denied request: {}", request, accessDeniedException); - throw new BizException(HttpStatus.FORBIDDEN, "权限不足"); + throw new ForbiddenException("权限不足"); }) ) .addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class) @@ -77,6 +77,7 @@ public class SecurityConfig { /** * Provides the BCrypt password encoder for credential hashing. + * * @return the BCrypt password encoder */ @Bean diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java index fc20ba4..f00fdb2 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java @@ -1,7 +1,6 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.ErrorResponse; -import com.onixbyte.deltaforceguide.exeption.BizException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; @@ -9,6 +8,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; /** * Global exception handler that translates exceptions into standard error responses. @@ -17,12 +17,11 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; */ @RestControllerAdvice public class GlobalExceptionHandler { - - @ExceptionHandler(BizException.class) - public ResponseEntity handleBizException(BizException exception) { - var status = exception.getStatus(); + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException(ResponseStatusException exception) { + var status = exception.getStatusCode(); return ResponseEntity.status(status) - .body(new ErrorResponse(exception.getMessage())); + .body(new ErrorResponse(exception.getReason())); } @ExceptionHandler(AuthenticationException.class) diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/BadRequestException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/BadRequestException.java new file mode 100644 index 0000000..60d37de --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/BadRequestException.java @@ -0,0 +1,11 @@ +package com.onixbyte.deltaforceguide.exeption; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class BadRequestException extends ResponseStatusException { + + public BadRequestException(String reason) { + super(HttpStatus.BAD_REQUEST, reason); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java deleted file mode 100644 index d1b7ff2..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.onixbyte.deltaforceguide.exeption; - -import org.springframework.http.HttpStatus; - -/** - * Custom runtime exception that carries an HTTP status code for API error responses. - * - * @author zihluwang - */ -public class BizException extends RuntimeException { - - /** - * The HTTP status code associated with this business exception. - *

- * This status code indicates the appropriate HTTP response status that should be returned to - * clients when this exception occurs. It enables consistent error handling across - * REST API endpoints. - */ - private final HttpStatus status; - - public BizException(String message) { - super(message); - this.status = HttpStatus.INTERNAL_SERVER_ERROR; - } - - public BizException(HttpStatus status, String message) { - super(message); - this.status = status; - } - - /** - * Returns the HTTP status code associated with this business exception. - * - * @return the HTTP status code that should be used in the error response - */ - public HttpStatus getStatus() { - return status; - } -} - diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/ForbiddenException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/ForbiddenException.java new file mode 100644 index 0000000..8f7cc55 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/ForbiddenException.java @@ -0,0 +1,11 @@ +package com.onixbyte.deltaforceguide.exeption; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class ForbiddenException extends ResponseStatusException { + + public ForbiddenException(String reason) { + super(HttpStatus.FORBIDDEN, reason); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/InternalServerErrorException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/InternalServerErrorException.java new file mode 100644 index 0000000..7eba228 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/InternalServerErrorException.java @@ -0,0 +1,11 @@ +package com.onixbyte.deltaforceguide.exeption; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class InternalServerErrorException extends ResponseStatusException { + + public InternalServerErrorException(String reason) { + super(HttpStatus.INTERNAL_SERVER_ERROR, reason); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/NotFoundException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/NotFoundException.java new file mode 100644 index 0000000..e88caac --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/NotFoundException.java @@ -0,0 +1,11 @@ +package com.onixbyte.deltaforceguide.exeption; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class NotFoundException extends ResponseStatusException { + + public NotFoundException(String reason) { + super(HttpStatus.NOT_FOUND, reason); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/UnauthorisedException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/UnauthorisedException.java new file mode 100644 index 0000000..5abbd86 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/UnauthorisedException.java @@ -0,0 +1,11 @@ +package com.onixbyte.deltaforceguide.exeption; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class UnauthorisedException extends ResponseStatusException { + + public UnauthorisedException(String reason) { + super(HttpStatus.UNAUTHORIZED, reason); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index 1a690e1..9a1da51 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -2,7 +2,7 @@ package com.onixbyte.deltaforceguide.filter; import com.auth0.jwt.exceptions.JWTVerificationException; import com.onixbyte.deltaforceguide.client.TokenClient; -import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.exeption.UnauthorisedException; import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.manager.UserRoleManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; @@ -16,11 +16,11 @@ import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.util.WebUtils; @@ -89,7 +89,7 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { var userWrapper = userManager.findByUsername(username); if (userWrapper.isEmpty()) { - throw new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录"); + throw new UnauthorisedException("登录已过期,请重新登录"); } var user = userWrapper.get(); @@ -110,8 +110,8 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { } catch (JWTVerificationException e) { log.error("JWT verification failed.", e); handlerExceptionResolver.resolveException(request, response, null, - new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录")); - } catch (BizException e) { + new UnauthorisedException("登录已过期,请重新登录")); + } catch (ResponseStatusException e) { handlerExceptionResolver.resolveException(request, response, null, e); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java index c1b552a..0184525 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java @@ -1,7 +1,8 @@ package com.onixbyte.deltaforceguide.interceptor; import com.onixbyte.crypto.util.CryptoUtil; -import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; +import com.onixbyte.deltaforceguide.exeption.UnauthorisedException; import com.onixbyte.deltaforceguide.manager.WebhookManager; import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; @@ -10,7 +11,6 @@ import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @@ -44,7 +44,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor { @NonNull Object handler ) { if (!(request instanceof RepeatedlyReadRequestWrapper req)) { - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + throw new InternalServerErrorException( "Request body is not readable"); } @@ -58,7 +58,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor { if (signatureHeader == null || signatureHeader.isBlank()) { log.warn("Missing {} header from ip={}", GitHubWebhookHeader.SIGNATURE_256, request.getRemoteAddr()); - throw new BizException(HttpStatus.UNAUTHORIZED, + throw new UnauthorisedException( "Missing webhook signature header"); } @@ -70,13 +70,13 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor { computed.getBytes(StandardCharsets.UTF_8), signatureHeader.getBytes(StandardCharsets.UTF_8))) { log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr()); - throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature"); + throw new UnauthorisedException("Invalid webhook signature"); } return true; } catch (NoSuchAlgorithmException | InvalidKeyException e) { log.error("Failed to compute HMAC-SHA256", e); - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + throw new InternalServerErrorException( "Failed to verify webhook signature"); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java index 6b6ac59..33be178 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java @@ -4,11 +4,10 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse; -import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.shared.JacksonModules; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.HttpStatus; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -76,7 +75,7 @@ public class DailyPasswordManager { .body(DailyPasswordResponse.class); if (Objects.isNull(response)) { - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "暂无每日密码数据。"); + throw new InternalServerErrorException("暂无每日密码数据。"); } redisTemplate.opsForValue().set(key, response, Duration.ofDays(1L)); diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java index 01e6baa..8fc1614 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -1,7 +1,8 @@ package com.onixbyte.deltaforceguide.security.provider; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; -import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; +import com.onixbyte.deltaforceguide.exeption.UnauthorisedException; import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.repository.UserCredentialRepository; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; @@ -10,7 +11,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Example; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -50,14 +50,14 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) { - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "用户认证失败,请稍后再试。"); + throw new InternalServerErrorException("用户认证失败,请稍后再试。"); } // get userContainer from database var userContainer = userManager.findByUsernameOrEmail(usernamePasswordAuthentication.getPrincipal()); if (userContainer.isEmpty()) { log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal()); - throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); + throw new UnauthorisedException("用户名或密码错误。"); } var user = userContainer.get(); @@ -68,12 +68,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro // get userContainer credentials from database var userCredentials = userCredentialRepository.findOne(Example.of(userCredentialExample)) - .orElseThrow(() -> new BizException(HttpStatus.UNAUTHORIZED, "您还没有配置密码,请联系管理员处理。")); + .orElseThrow(() -> new UnauthorisedException("您还没有配置密码,请联系管理员处理。")); // validate password if (!passwordEncoder.matches(usernamePasswordAuthentication.getCredentials(), userCredentials.getCredential())) { log.error("User {} is trying to authenticate but password is incorrect.", usernamePasswordAuthentication.getPrincipal()); - throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); + throw new UnauthorisedException("用户名或密码错误。"); } // erase credentials diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index da41902..440ea31 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -2,11 +2,10 @@ package com.onixbyte.deltaforceguide.service; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.entity.User; -import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.stereotype.Service; @@ -33,7 +32,6 @@ public class AuthService { * * @param request the login credentials containing principle and password * @return the authenticated {@link User} - * @throws BizException if authentication fails or the result type is unexpected */ public User login(LoginRequest request) { var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication @@ -43,7 +41,7 @@ public class AuthService { "Type mismatched, required type is UsernamePasswordAuthentication but got {}.", _authentication.getClass() ); - throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "登录服务异常,请稍后再试。"); + throw new InternalServerErrorException("登录服务异常,请稍后再试。"); } return authentication.getDetails(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java index 5f5098b..f113276 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java @@ -5,7 +5,7 @@ import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.enumeration.FirearmType; -import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.exeption.NotFoundException; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -84,7 +84,7 @@ public class FirearmService { */ public FirearmResponse updateFirearm(Long id, FirearmRequest request) { var firearm = firearmRepository.findById(id) - .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); + .orElseThrow(() -> new NotFoundException("Firearm not found: " + id)); firearm.setName(request.name()); firearm.setType(request.type()); @@ -105,7 +105,7 @@ public class FirearmService { */ public void deleteFirearm(Long id) { Firearm firearm = firearmRepository.findById(id) - .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); + .orElseThrow(() -> new NotFoundException("Firearm not found: " + id)); firearmRepository.delete(firearm); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java index 0d4a433..cabefe7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -1,14 +1,10 @@ package com.onixbyte.deltaforceguide.service; -import com.onixbyte.deltaforceguide.domain.dto.BuildSummaryResponse; -import com.onixbyte.deltaforceguide.domain.dto.ChangePasswordRequest; -import com.onixbyte.deltaforceguide.domain.dto.PageResponse; -import com.onixbyte.deltaforceguide.domain.dto.UpdateProfileRequest; -import com.onixbyte.deltaforceguide.domain.dto.UserProfileResponse; +import com.onixbyte.deltaforceguide.domain.dto.*; import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.domain.entity.UserRole; -import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.exeption.BadRequestException; import com.onixbyte.deltaforceguide.manager.UserCredentialManager; import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.manager.UserRoleManager; @@ -213,12 +209,11 @@ public class UserService { * * @param user the authenticated user * @param request the change password request containing old and new passwords - * @throws BizException if the old password is incorrect */ public void changePassword(User user, ChangePasswordRequest request) { var credential = userCredentialManager.findByUserIdAndProvider(user.getId(), CredentialProvider.LOCAL); if (credential.isEmpty() || !passwordEncoder.matches(request.oldPassword(), credential.get().getCredential())) { - throw new BizException(HttpStatus.BAD_REQUEST, "旧密码不正确。"); + throw new BadRequestException("旧密码不正确。"); } var encoded = passwordEncoder.encode(request.newPassword()); upsertCredential(user.getId(), CredentialProvider.LOCAL, encoded); @@ -243,11 +238,10 @@ public class UserService { * * @param userId the target user ID * @param role the role to assign - * @throws BizException if attempting to assign a protected role */ public void assignRole(Long userId, String role) { if (!Role.ROLE_ADMIN.equals(role)) { - throw new BizException(HttpStatus.BAD_REQUEST, "只能通过API分配ROLE_ADMIN角色。"); + throw new BadRequestException("只能通过API分配ROLE_ADMIN角色。"); } var user = ensureUserExists(userId); if (userRoleManager.existsByUserIdAndRole(userId, role)) { @@ -265,11 +259,10 @@ public class UserService { * * @param userId the target user ID * @param role the role to remove - * @throws BizException if attempting to remove a protected role */ public void removeRole(Long userId, String role) { if (!Role.ROLE_ADMIN.equals(role)) { - throw new BizException(HttpStatus.BAD_REQUEST, "只能通过API移除ROLE_ADMIN角色。"); + throw new BadRequestException("只能通过API移除ROLE_ADMIN角色。"); } ensureUserExists(userId); userRoleManager.deleteByUserIdAndRole(userId, role); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index e864a77..6b7810e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -1,14 +1,13 @@ package com.onixbyte.deltaforceguide.service; import com.onixbyte.deltaforceguide.domain.dto.*; +import com.onixbyte.deltaforceguide.exeption.BadRequestException; import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.manager.WebhookManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import com.onixbyte.deltaforceguide.exeption.BizException; import org.yaml.snakeyaml.Yaml; import java.time.Duration; @@ -111,7 +110,7 @@ public class WebhookService { toLong(data.get("firearmId")), (String) data.get("firearmName")); if (firearmId == null) { - throw new BizException(HttpStatus.BAD_REQUEST, + throw new BadRequestException( "YAML must contain firearmId or firearmName"); } String name = (String) data.get("name"); From ac877ab8f08a295348c010a73df60c79d379a39a Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 6 Aug 2026 17:51:38 +0800 Subject: [PATCH 100/117] fix(auth): enforce single role per user Change the user_role primary key from (user_id, role) to user_id alone, so each user holds at most one role row. --- src/main/resources/db/migration/V7__user_role.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V7__user_role.sql b/src/main/resources/db/migration/V7__user_role.sql index 57a0049..69b69e5 100644 --- a/src/main/resources/db/migration/V7__user_role.sql +++ b/src/main/resources/db/migration/V7__user_role.sql @@ -2,5 +2,5 @@ CREATE TABLE user_role ( user_id BIGINT NOT NULL REFERENCES app_user (id), role VARCHAR(32) NOT NULL, - CONSTRAINT user_role_pkey PRIMARY KEY (user_id, role) + CONSTRAINT user_role_pkey PRIMARY KEY (user_id) ); From 471eda53536bd04830c20a0e2fe0a2b0e308e5f0 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 10 Aug 2026 14:33:45 +0800 Subject: [PATCH 101/117] refactor(webhook): generalise controller routes - Rename GitHubWebhookController to WebhookController - Move class-level mapping from /webhooks/github to /webhooks - Scope the GitHub endpoint to /webhooks/github via @PostMapping --- ...WebhookController.java => WebhookController.java} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename src/main/java/com/onixbyte/deltaforceguide/controller/{GitHubWebhookController.java => WebhookController.java} (84%) diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java similarity index 84% rename from src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java rename to src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java index 8b6f3b6..2fc8bda 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/WebhookController.java @@ -14,21 +14,21 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "GitHub WebHook") +@Tag(name = "WebHook") @RestController -@RequestMapping("/webhooks/github") -public class GitHubWebhookController { +@RequestMapping("/webhooks") +public class WebhookController { - private static final Logger log = LoggerFactory.getLogger(GitHubWebhookController.class); + private static final Logger log = LoggerFactory.getLogger(WebhookController.class); private final WebhookService webhookService; - public GitHubWebhookController(WebhookService webhookService) { + public WebhookController(WebhookService webhookService) { this.webhookService = webhookService; } @Operation(description = "GitHub WebHook 接口") - @PostMapping + @PostMapping("/github") public ResponseEntity handleWebhook( @RequestHeader(GitHubWebhookHeader.EVENT) String event, @RequestBody GitHubIssueRequest request From 3f60df9de21aea90acb330e33a9b5893c9391d11 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 10 Aug 2026 15:49:49 +0800 Subject: [PATCH 102/117] feat(auth): add role-based authorization - Load the user's role during authentication and expose it as a granted authority on the authenticated token - Add @RequiresAdmin and @RequiresSuperUser method-level annotations for role-gated endpoints - Introduce a shared BaseRepository with specification support - Replace Example-based credential queries with JPA specifications --- .../repository/BaseRepository.java | 7 ++++ .../repository/FirearmRepository.java | 2 +- .../repository/ModificationRepository.java | 2 +- .../repository/UserCredentialRepository.java | 9 +++-- .../repository/UserRepository.java | 2 +- .../repository/UserRoleRepository.java | 2 +- .../security/annotation/RequiresAdmin.java | 14 ++++++++ .../annotation/RequiresSuperUser.java | 14 ++++++++ .../UsernamePasswordAuthentication.java | 4 +++ ...sernamePasswordAuthenticationProvider.java | 36 ++++++++++++++----- .../onixbyte/deltaforceguide/shared/Role.java | 4 +++ 11 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java new file mode 100644 index 0000000..93ab920 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java @@ -0,0 +1,7 @@ +package com.onixbyte.deltaforceguide.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface BaseRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index 46c84b9..881ee97 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -15,7 +15,7 @@ import java.util.List; * @author zihluwang */ @Repository -public interface FirearmRepository extends JpaRepository { +public interface FirearmRepository extends BaseRepository { Page findAllByType(FirearmType type, Pageable pageable); diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index 3d9fcc0..bd16813 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -20,7 +20,7 @@ import java.util.Optional; * @author zihluwang */ @Repository -public interface ModificationRepository extends JpaRepository { +public interface ModificationRepository extends BaseRepository { @EntityGraph(attributePaths = {"firearm"}) Page findAllBy(Pageable pageable); diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java index 53776e3..36c2181 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java @@ -2,10 +2,7 @@ package com.onixbyte.deltaforceguide.repository; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.domain.entity.UserCredentialId; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -18,7 +15,9 @@ import java.util.Optional; * @author zihluwang */ @Repository -public interface UserCredentialRepository extends JpaRepository { +public interface UserCredentialRepository extends + JpaRepository, + JpaSpecificationExecutor { /** * Find all credentials belonging to a given user. diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java index 7a0a363..cf81ca8 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java @@ -16,7 +16,7 @@ import java.util.Optional; * @author zihluwang */ @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends BaseRepository { @Override @EntityGraph(attributePaths = {"credentials"}) diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java index f2ce1e8..a227b72 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java @@ -17,7 +17,7 @@ import java.util.List; * @author zihluwang */ @Repository -public interface UserRoleRepository extends JpaRepository { +public interface UserRoleRepository extends BaseRepository { /** * Find all roles assigned to a given user. diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java new file mode 100644 index 0000000..83ebd28 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java @@ -0,0 +1,14 @@ +package com.onixbyte.deltaforceguide.security.annotation; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("isAuthenticated() && hasAnyRole('ROLE_ADMIN', 'ROLE_SUPER_USER')") +public @interface RequiresAdmin { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java new file mode 100644 index 0000000..1be1899 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java @@ -0,0 +1,14 @@ +package com.onixbyte.deltaforceguide.security.annotation; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("isAuthenticated() && hasRole('ROLE_SUPER_USER')") +public @interface RequiresSuperUser { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java index c6eb826..cf1bc84 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java @@ -93,5 +93,9 @@ public class UsernamePasswordAuthentication implements Authentication, Credentia public void setDetails(User user) { this.user = user; } + + public void setAuthorities(Collection authorities) { + this.authorities = new ArrayList<>(authorities); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java index 8fc1614..af29ba1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -1,22 +1,28 @@ package com.onixbyte.deltaforceguide.security.provider; -import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.domain.entity.UserRole; import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.exeption.UnauthorisedException; import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.repository.UserCredentialRepository; +import com.onixbyte.deltaforceguide.repository.UserRoleRepository; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; import com.onixbyte.deltaforceguide.shared.CredentialProvider; +import com.onixbyte.deltaforceguide.shared.Role; +import jakarta.persistence.criteria.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.Example; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; + /** * Authentication provider that validates username/password credentials against stored BCrypt hashes. * @@ -29,16 +35,18 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro private final UserManager userManager; private final PasswordEncoder passwordEncoder; private final UserCredentialRepository userCredentialRepository; + private final UserRoleRepository userRoleRepository; @Autowired public UsernamePasswordAuthenticationProvider( UserManager userManager, PasswordEncoder passwordEncoder, - UserCredentialRepository userCredentialRepository - ) { + UserCredentialRepository userCredentialRepository, + UserRoleRepository userRoleRepository) { this.userManager = userManager; this.passwordEncoder = passwordEncoder; this.userCredentialRepository = userCredentialRepository; + this.userRoleRepository = userRoleRepository; } /** @@ -62,12 +70,13 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro var user = userContainer.get(); - var userCredentialExample = new UserCredential(); - userCredentialExample.setUserId(user.getId()); - userCredentialExample.setProvider(CredentialProvider.LOCAL); - // get userContainer credentials from database - var userCredentials = userCredentialRepository.findOne(Example.of(userCredentialExample)) + var userCredentials = userCredentialRepository.findOne((root, query, cb) -> { + var predicates = new ArrayList(); + predicates.add(cb.equal(root.get("provider"), CredentialProvider.LOCAL)); + predicates.add(cb.equal(root.get("userId"), user.getId())); + return cb.and(predicates.toArray(Predicate[]::new)); + }) .orElseThrow(() -> new UnauthorisedException("您还没有配置密码,请联系管理员处理。")); // validate password @@ -76,12 +85,21 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro throw new UnauthorisedException("用户名或密码错误。"); } + var role = userRoleRepository.findOne((root, query, cb) -> cb.equal(root.get("userId"), user.getId())) + .orElseGet(() -> { + var ur = new UserRole(); + ur.setUserId(user.getId()); + ur.setRole(Role.ROLE_USER); + return ur; + }); + // erase credentials usernamePasswordAuthentication.eraseCredentials(); // set values usernamePasswordAuthentication.setAuthenticated(true); usernamePasswordAuthentication.setDetails(user); + usernamePasswordAuthentication.setAuthorities(List.of(new SimpleGrantedAuthority(role.getRole()))); return usernamePasswordAuthentication; } diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java b/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java index 719321e..3bea7df 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java @@ -7,6 +7,10 @@ package com.onixbyte.deltaforceguide.shared; */ public class Role { + public static final String ROLE_USER_VALUE = "USER"; + public static final String ROLE_ADMIN_VALUE = "ADMIN"; + public static final String ROLE_SUPER_USER_VALUE = "SUPER_USER"; + public static final String ROLE_USER = "ROLE_USER"; public static final String ROLE_ADMIN = "ROLE_ADMIN"; public static final String ROLE_SUPER_USER = "ROLE_SUPER_USER"; From a765c705e6df69544c11700bf35391e740d125f8 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Mon, 10 Aug 2026 15:50:55 +0800 Subject: [PATCH 103/117] fix(auth): use bare role names in annotations --- .../deltaforceguide/security/annotation/RequiresAdmin.java | 2 +- .../deltaforceguide/security/annotation/RequiresSuperUser.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java index 83ebd28..2370e1b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAdmin.java @@ -9,6 +9,6 @@ import java.lang.annotation.Target; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) -@PreAuthorize("isAuthenticated() && hasAnyRole('ROLE_ADMIN', 'ROLE_SUPER_USER')") +@PreAuthorize("isAuthenticated() && hasAnyRole('ADMIN', 'SUPER_USER')") public @interface RequiresAdmin { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java index 1be1899..ed9b577 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresSuperUser.java @@ -9,6 +9,6 @@ import java.lang.annotation.Target; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) -@PreAuthorize("isAuthenticated() && hasRole('ROLE_SUPER_USER')") +@PreAuthorize("isAuthenticated() && hasRole('SUPER_USER')") public @interface RequiresSuperUser { } From 0a7882c5ce9f18fbee514819142e882143d2482e Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 11 Aug 2026 00:54:46 +0800 Subject: [PATCH 104/117] feat(auth): add configurable token renew threshold - Add renew-threshold JWT config option with PT5M default - Default valid-time to PT2H in TokenProperties - Expand comments in the prod config example --- config/application-prod.yaml.example | 46 ++++++++++++++++--- .../properties/TokenProperties.java | 4 +- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/config/application-prod.yaml.example b/config/application-prod.yaml.example index e68c145..010be28 100644 --- a/config/application-prod.yaml.example +++ b/config/application-prod.yaml.example @@ -23,28 +23,58 @@ app: path: '/' max-age: P1D cors: - allowed-origins: # Cross-origin allowed origins + # Cross-origin allowed origins + allowed-origins: - "http://localhost:5173" # Dev server for vite. - "http://localhost:4173" # Preview server for vite. - allow-credentials: true # Must be set to `true` since we are using cookie. You can change it only when you have modified how this application executing authentication. + + # Must be set to `true` since we are using cookie. You can change it only when you have + # modified how this application executing authentication. + allow-credentials: true + + # Whether allow private network addresses. allow-private-network: false + + # Allowed headers. allowed-headers: - "Content-Type" - "Authorization" + + # Allowed methods. + # + # Since the usage of HttpMethod from Spring Framework, all methods should be UPPER CASE. + # e.g., GET, POST, PUT, PATCH, DELETE, OPTIONS allowed-methods: - GET - POST - PUT - PATCH - DELETE + + # Headers are allowed being displayed on Web pages. exposed-headers: - "Content-Type" - "Authorization" + + # Cross-origin policy max age. max-age: PT2H jwt: - issuer: dfguide.local # Issuer host - secret: qwertyuiopasdfghjklzxcvbnm123456 # JWT singing secret, a 32-byte long or longer string is recommended - valid-time: PT2H # JWT valid duration + # Issuer host + issuer: dfguide.local + + # Token singing secret, a 32-byte long or longer string is recommended + secret: qwertyuiopasdfghjklzxcvbnm123456 + + # Token valid duration. + # + # Default to PT2H (2 hours). + valid-time: PT2H + + # Token renew threshold. If valid time of this token is less than this value, then a new token + # will be delivered to the user. + # + # Default to PT5M (5 min). + renew-threshold: PT5M webhook: github: secret: 123456 @@ -52,6 +82,8 @@ app: springdoc: api-docs: - enabled: true # Set to `false` if you do not need api docs (recommended in production mode). + # Set to `false` if you do not need api docs (recommended in production mode). + enabled: true swagger-ui: - enabled: true # Set to `false` if you do not need swagger ui (recommended in production mode). \ No newline at end of file + # Set to `false` if you do not need swagger ui (recommended in production mode). + enabled: true \ No newline at end of file diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java index 74c693c..4deb33a 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java @@ -1,6 +1,7 @@ package com.onixbyte.deltaforceguide.properties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; import java.time.Duration; @@ -16,6 +17,7 @@ import java.time.Duration; public record TokenProperties( String issuer, String secret, - Duration validTime + @DefaultValue("PT2H") Duration validTime, + @DefaultValue("PT5M") Duration renewThreshold ) { } From 17b919705e7e366722b6d12f2e055b6befe9e852 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 11 Aug 2026 12:07:18 +0800 Subject: [PATCH 105/117] docs(config): document prod config options - Add comments to datasource, redis, cookie and webhook sections - Polish existing CORS and springdoc comments - Enable secure cookie to satisfy same-site none - Fix token secret and GitHub username typos --- config/application-prod.yaml.example | 88 ++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/config/application-prod.yaml.example b/config/application-prod.yaml.example index 010be28..17f82c2 100644 --- a/config/application-prod.yaml.example +++ b/config/application-prod.yaml.example @@ -1,48 +1,81 @@ spring: datasource: + # PostgreSQL connection URL. url: jdbc:postgresql://localhost:5432/dfguide_dev + + # PostgreSQL username. username: postgres + + # PostgreSQL password. password: 123456 + + # PostgreSQL JDBC driver class name. driver-class-name: org.postgresql.Driver data: redis: + # Redis server host. host: localhost + + # Redis server port. port: 6379 + + # Redis database index. + # + # Default to 0. database: 0 - # password: qwerty # Uncomment if password is necessary + + # Redis server password. + # + # Uncomment if the server requires a password. + # + # password: qwerty logging: pattern: - # dateformat: dd MMM yyyy HH:mm:ss.SSS # Modify this for custom date format. + # Date format used in log output. Uncomment to use a custom format. + # + # Default to the date format provided by Spring Boot. + # dateformat: dd MMM yyyy HH:mm:ss.SSS app: cookie: + # Whether the cookie is accessible from JavaScript. Keep `true` to mitigate XSS attacks. http-only: true - secure: false + + # Whether the cookie is only sent over HTTPS. Set to `true` in production is recommended. + secure: true + + # SameSite policy of the cookie. + # + # NOTE that `none` requires `secure` to be `true`. same-site: none + + # Cookie path. path: '/' + + # Cookie max age. max-age: P1D cors: - # Cross-origin allowed origins - allowed-origins: - - "http://localhost:5173" # Dev server for vite. - - "http://localhost:4173" # Preview server for vite. + # Cross-origin allowed origins. Add your custom origins here. + allowed-origins: [] - # Must be set to `true` since we are using cookie. You can change it only when you have - # modified how this application executing authentication. + # Whether to allow credentials (cookies, authorisation headers) in cross-origin requests. + # + # Must be `true` since the application uses cookies for authentication. Change it only when + # the authentication mechanism no longer relies on cookies. allow-credentials: true - # Whether allow private network addresses. + # Whether to allow requests originating from private network addresses. allow-private-network: false - # Allowed headers. + # Headers allowed in cross-origin requests. allowed-headers: - "Content-Type" - "Authorization" - # Allowed methods. + # HTTP methods allowed in cross-origin requests. # - # Since the usage of HttpMethod from Spring Framework, all methods should be UPPER CASE. + # Methods are matched against Spring's HttpMethod, so they must be written in UPPER CASE. # e.g., GET, POST, PUT, PATCH, DELETE, OPTIONS allowed-methods: - GET @@ -51,18 +84,20 @@ app: - PATCH - DELETE - # Headers are allowed being displayed on Web pages. + # Response headers exposed to the browser in cross-origin responses. exposed-headers: - "Content-Type" - "Authorization" - # Cross-origin policy max age. + # How long the CORS pre-flight response may be cached by the browser. + # + # Default to PT2H (2 hours). max-age: PT2H jwt: - # Issuer host + # Issuer identifier, domain name is recommended. issuer: dfguide.local - # Token singing secret, a 32-byte long or longer string is recommended + # Token signing secret, a 32-byte long or longer string is recommended. secret: qwertyuiopasdfghjklzxcvbnm123456 # Token valid duration. @@ -70,20 +105,27 @@ app: # Default to PT2H (2 hours). valid-time: PT2H - # Token renew threshold. If valid time of this token is less than this value, then a new token - # will be delivered to the user. + # Token renew threshold. + # + # If valid time of this token is less than this value, then a new token will be delivered to + # the user. # # Default to PT5M (5 min). renew-threshold: PT5M webhook: github: + # GitHub webhook secret. Used to verify the signature of incoming webhook requests. secret: 123456 - allowed-users: [ octotcat, onixbyte ] + + # GitHub usernames allowed to trigger webhook events. + allowed-users: [ octocat, onixbyte ] springdoc: api-docs: - # Set to `false` if you do not need api docs (recommended in production mode). + # Whether to expose the OpenAPI documentation. Set to `false` if you do not need it + # (recommended in production mode). enabled: true swagger-ui: - # Set to `false` if you do not need swagger ui (recommended in production mode). - enabled: true \ No newline at end of file + # Whether to expose the Swagger UI. Set to `false` if you do not need it + # (recommended in production mode). + enabled: true From dadd9a72214045705cceb584c3b2dac08f69cba0 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 11 Aug 2026 12:23:17 +0800 Subject: [PATCH 106/117] refactor(auth): mark resolver params non-null - Annotate resolveArgument parameters with jspecify @NonNull --- .../security/resolver/CurrentUserArgumentResolver.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java index 3ab241e..5a5b557 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java @@ -2,6 +2,7 @@ package com.onixbyte.deltaforceguide.security.resolver; import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import org.jspecify.annotations.NonNull; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; @@ -30,9 +31,9 @@ public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolve @Override public User resolveArgument( - MethodParameter parameter, + @NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, + @NonNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) { var auth = SecurityContextHolder.getContext().getAuthentication(); From 1aeefb673315197e1d81847eb298af79e69ff258 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 11 Aug 2026 12:48:20 +0800 Subject: [PATCH 107/117] refactor(auth): centralise cookie building - Move cookie construction logic into CookieManager - Remove CookieService --- .../controller/AuthController.java | 23 +------ .../filter/TokenAuthenticationFilter.java | 10 +-- .../manager/CookieManager.java | 46 +++++++++++++ .../deltaforceguide/service/AuthService.java | 36 +++++++++- .../service/CookieService.java | 67 ------------------- 5 files changed, 89 insertions(+), 93 deletions(-) delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index db0f42e..4648bb3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -2,11 +2,8 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.dto.UserResponse; -import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.service.AuthService; -import com.onixbyte.deltaforceguide.service.CookieService; -import com.onixbyte.deltaforceguide.shared.CookieName; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.HttpHeaders; @@ -17,9 +14,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.time.Duration; -import java.time.LocalDateTime; - /** * REST controller for user authentication endpoints (login, logout). * @@ -31,34 +25,23 @@ import java.time.LocalDateTime; public class AuthController { private final AuthService authService; - private final TokenClient tokenClient; - private final CookieService cookieService; - public AuthController(AuthService authService, TokenClient tokenClient, CookieService cookieService) { + public AuthController(AuthService authService) { this.authService = authService; - this.tokenClient = tokenClient; - this.cookieService = cookieService; } @Operation(description = "用户登录") @PostMapping("/login") public ResponseEntity login(@Validated @RequestBody LoginRequest request) { var user = authService.login(request); - var currentTime = LocalDateTime.now(); - var accessToken = tokenClient.generateToken(user); - var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken); - var cookieMaxAge = accessTokenCookie.getMaxAge(); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - .body(UserResponse.from(user, currentTime.plus(cookieMaxAge))); + return authService.getUserResponseResponseEntity(user); } @RequiresAuth @Operation(description = "退出登录") @PostMapping("/logout") public ResponseEntity logout() { - var expiredCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO); + var expiredCookie = authService.logout(); return ResponseEntity.noContent() .header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) .build(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index 9a1da51..0bbe14f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -3,10 +3,10 @@ package com.onixbyte.deltaforceguide.filter; import com.auth0.jwt.exceptions.JWTVerificationException; import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.exeption.UnauthorisedException; +import com.onixbyte.deltaforceguide.manager.CookieManager; import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.manager.UserRoleManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; -import com.onixbyte.deltaforceguide.service.CookieService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; @@ -45,20 +45,20 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { private final UserManager userManager; private final UserRoleManager userRoleManager; private final TokenClient tokenClient; - private final CookieService cookieService; + private final CookieManager cookieManager; private final HandlerExceptionResolver handlerExceptionResolver; public TokenAuthenticationFilter( UserManager userManager, UserRoleManager userRoleManager, TokenClient tokenClient, - CookieService cookieService, + CookieManager cookieManager, HandlerExceptionResolver handlerExceptionResolver ) { this.userManager = userManager; this.userRoleManager = userRoleManager; this.tokenClient = tokenClient; - this.cookieService = cookieService; + this.cookieManager = cookieManager; this.handlerExceptionResolver = handlerExceptionResolver; } @@ -102,7 +102,7 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { if (shouldRenew(decodedToken.getExpiresAt().toInstant())) { var renewedToken = tokenClient.generateToken(user); - var renewedTokenCookie = cookieService.buildCookie("AccessToken", renewedToken); + var renewedTokenCookie = cookieManager.buildCookie("AccessToken", renewedToken); response.addHeader(HttpHeaders.SET_COOKIE, renewedTokenCookie.toString()); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java index f6ef16f..209c9dc 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java @@ -2,6 +2,7 @@ package com.onixbyte.deltaforceguide.manager; import com.onixbyte.deltaforceguide.properties.CookieProperties; import org.springframework.boot.web.server.Cookie; +import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; import java.time.Duration; @@ -39,4 +40,49 @@ public class CookieManager { public Duration getMaxAge() { return cookieProperties.maxAge(); } + + /** + * Builds a response cookie with the default max age from configuration. + * + * @param cookieName the cookie name + * @param value the cookie value + * @return a configured ResponseCookie + */ + public ResponseCookie buildCookie(String cookieName, String value) { + return buildCookieInternal(cookieName, value, getMaxAge()); + } + + /** + * Builds a response cookie with a custom valid duration. + * + * @param cookieName the cookie name + * @param value the cookie value + * @param validDuration the cookie's max age + * @return a configured ResponseCookie + */ + public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) { + return buildCookieInternal(cookieName, value, validDuration); + } + + /** + * Creates a response cookie builder with specified name, value and valid duration. + * + * @param name name of the cookie + * @param value value of the cookie + * @param maxAge valid duration of the cookie + * @return cookie builder + */ + protected ResponseCookie buildCookieInternal( + String name, + String value, + Duration maxAge + ) { + return ResponseCookie.from(name, value) + .secure(getSecure()) + .maxAge(maxAge) + .httpOnly(getHttpOnly()) + .path(getPath()) + .sameSite(getSameSite().attributeValue()) + .build(); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index 440ea31..99743b2 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -1,14 +1,25 @@ package com.onixbyte.deltaforceguide.service; +import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; +import com.onixbyte.deltaforceguide.domain.dto.UserResponse; import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; +import com.onixbyte.deltaforceguide.manager.CookieManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import com.onixbyte.deltaforceguide.shared.CookieName; +import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.time.LocalDateTime; + /** * Service handling user authentication, login, and session management. * @@ -19,9 +30,17 @@ public class AuthService { private static final Logger log = LoggerFactory.getLogger(AuthService.class); private final AuthenticationManager authenticationManager; + private final TokenClient tokenClient; + private final CookieManager cookieManager; - public AuthService(AuthenticationManager authenticationManager) { + public AuthService( + AuthenticationManager authenticationManager, + TokenClient tokenClient, + CookieManager cookieManager + ) { this.authenticationManager = authenticationManager; + this.tokenClient = tokenClient; + this.cookieManager = cookieManager; } /** @@ -47,4 +66,19 @@ public class AuthService { return authentication.getDetails(); } + @NonNull + public ResponseEntity getUserResponseResponseEntity(User user) { + var currentTime = LocalDateTime.now(); + var accessToken = tokenClient.generateToken(user); + var accessTokenCookie = cookieManager.buildCookie(CookieName.ACCESS_TOKEN, accessToken); + var cookieMaxAge = accessTokenCookie.getMaxAge(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + .body(UserResponse.from(user, currentTime.plus(cookieMaxAge))); + } + + public HttpCookie logout() { + return cookieManager.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java deleted file mode 100644 index eb93086..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.onixbyte.deltaforceguide.service; - -import com.onixbyte.deltaforceguide.manager.CookieManager; -import org.springframework.http.ResponseCookie; -import org.springframework.stereotype.Service; - -import java.time.Duration; - -/** - * Service for building HTTP cookies with configurable properties. - * - * @author zihluwang - */ -@Service -public class CookieService { - - private final CookieManager cookieManager; - - public CookieService(CookieManager cookieManager) { - this.cookieManager = cookieManager; - } - - /** - * Builds a response cookie with the default max age from configuration. - * - * @param cookieName the cookie name - * @param value the cookie value - * @return a configured ResponseCookie - */ - public ResponseCookie buildCookie(String cookieName, String value) { - return buildCookieInternal(cookieName, value, cookieManager.getMaxAge()); - } - - /** - * Builds a response cookie with a custom valid duration. - * - * @param cookieName the cookie name - * @param value the cookie value - * @param validDuration the cookie's max age - * @return a configured ResponseCookie - */ - public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) { - return buildCookieInternal(cookieName, value, validDuration); - } - - /** - * Creates a response cookie builder with specified name, value and valid duration. - * - * @param name name of the cookie - * @param value value of the cookie - * @param maxAge valid duration of the cookie - * @return cookie builder - */ - protected ResponseCookie buildCookieInternal( - String name, - String value, - Duration maxAge - ) { - return ResponseCookie.from(name, value) - .secure(cookieManager.getSecure()) - .maxAge(maxAge) - .httpOnly(cookieManager.getHttpOnly()) - .path(cookieManager.getPath()) - .sameSite(cookieManager.getSameSite().attributeValue()) - .build(); - } -} From 8f98d020447b3bbef6ce514babbf08c0b096069b Mon Sep 17 00:00:00 2001 From: zihluwang Date: Tue, 11 Aug 2026 13:32:58 +0800 Subject: [PATCH 108/117] feat(auth): add user registration endpoint - Validate username, email and password on register - Reject duplicate usernames and email addresses - Create user with LOCAL credential and default USER role Closes #3 --- .../controller/AuthController.java | 8 +++ .../domain/dto/RegisterRequest.java | 29 +++++++++++ .../deltaforceguide/manager/UserManager.java | 43 +++++++++++++++- .../deltaforceguide/service/AuthService.java | 50 ++++++++++++++++++- 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index 4648bb3..e1071ed 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -1,6 +1,7 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; +import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; import com.onixbyte.deltaforceguide.domain.dto.UserResponse; import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.service.AuthService; @@ -46,4 +47,11 @@ public class AuthController { .header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) .build(); } + + @Operation(description = "用户注册") + @PostMapping("/register") + public ResponseEntity register(@Validated @RequestBody RegisterRequest request) { + var user = authService.register(request); + return authService.getUserResponseResponseEntity(user); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java new file mode 100644 index 0000000..92b8040 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java @@ -0,0 +1,29 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** + * Registration request containing the new user account information. + * + * @author zihluwang + */ +@Schema(description = "注册请求") +public record RegisterRequest( + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED) + String username, + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + @Schema(description = "邮箱", requiredMode = Schema.RequiredMode.REQUIRED) + String email, + @Schema(description = "昵称") + String nickname, + @Schema(description = "头像链接") + String avatarUrl, + @NotBlank(message = "密码不能为空") + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) + String password +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java index d63c0f7..47b108d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java @@ -1,7 +1,9 @@ package com.onixbyte.deltaforceguide.manager; import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.domain.entity.UserRole; import com.onixbyte.deltaforceguide.repository.UserRepository; +import com.onixbyte.deltaforceguide.repository.UserRoleRepository; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -17,9 +19,11 @@ import java.util.Optional; public class UserManager { private final UserRepository userRepository; + private final UserRoleRepository userRoleRepository; - public UserManager(UserRepository userRepository) { + public UserManager(UserRepository userRepository, UserRoleRepository userRoleRepository) { this.userRepository = userRepository; + this.userRoleRepository = userRoleRepository; } /** @@ -83,5 +87,42 @@ public class UserManager { public Optional findByUsernameOrEmail(String principal) { return userRepository.findByUsernameOrEmail(principal); } + + /** + * Checks whether a username is already registered. + * + * @param username the username to check + * @return true if the username is already taken + */ + public boolean existsByUsername(String username) { + return userRepository.existsByUsername(username); + } + + /** + * Checks whether an email address is already registered. + * + * @param email the email address to check + * @return true if the email address is already taken + */ + public boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); + } + + /** + * Persists a new user together with an initial role assignment in a single transaction. + * + * @param user the user to persist, whose credentials are cascaded + * @param role the initial role name to assign + * @return the saved user + */ + @Transactional + public User createWithRole(User user, String role) { + var saved = userRepository.save(user); + userRoleRepository.save(UserRole.builder() + .user(saved) + .role(role) + .build()); + return saved; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index 99743b2..82ffbb0 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -2,12 +2,18 @@ package com.onixbyte.deltaforceguide.service; import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; +import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; import com.onixbyte.deltaforceguide.domain.dto.UserResponse; import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.exeption.BadRequestException; import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.manager.CookieManager; +import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; import com.onixbyte.deltaforceguide.shared.CookieName; +import com.onixbyte.deltaforceguide.shared.CredentialProvider; +import com.onixbyte.deltaforceguide.shared.Role; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,6 +21,7 @@ import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.time.Duration; @@ -32,15 +39,21 @@ public class AuthService { private final AuthenticationManager authenticationManager; private final TokenClient tokenClient; private final CookieManager cookieManager; + private final UserManager userManager; + private final PasswordEncoder passwordEncoder; public AuthService( AuthenticationManager authenticationManager, TokenClient tokenClient, - CookieManager cookieManager + CookieManager cookieManager, + UserManager userManager, + PasswordEncoder passwordEncoder ) { this.authenticationManager = authenticationManager; this.tokenClient = tokenClient; this.cookieManager = cookieManager; + this.userManager = userManager; + this.passwordEncoder = passwordEncoder; } /** @@ -66,6 +79,41 @@ public class AuthService { return authentication.getDetails(); } + /** + * Registers a new user account with the LOCAL password provider and the default USER role. + * + * @param request the registration information + * @return the newly created {@link User} + */ + public User register(RegisterRequest request) { + var username = request.username().trim(); + var email = request.email().trim(); + + if (userManager.existsByUsername(username)) { + throw new BadRequestException("用户名已被注册。"); + } + if (userManager.existsByEmail(email)) { + throw new BadRequestException("该邮箱已被注册。"); + } + + var now = LocalDateTime.now(); + var user = User.builder() + .username(username) + .email(email) + .nickname(request.nickname()) + .avatarUrl(request.avatarUrl()) + .createdAt(now) + .updatedAt(now) + .build(); + var credential = UserCredential.builder() + .provider(CredentialProvider.LOCAL) + .credential(passwordEncoder.encode(request.password())) + .build(); + user.addCredential(credential); + + return userManager.createWithRole(user, Role.ROLE_USER); + } + @NonNull public ResponseEntity getUserResponseResponseEntity(User user) { var currentTime = LocalDateTime.now(); From d9b74ae2e345070b495206f37570c8ce265e05be Mon Sep 17 00:00:00 2001 From: zihluwang Date: Wed, 19 Aug 2026 13:39:36 +0800 Subject: [PATCH 109/117] feat(auth): add email verification codes - Add POST /auth/verification-code endpoint to email a code - Store codes in Redis and verify them during registration - Send verification emails asynchronously via Thymeleaf - Replace email_verified column with code-based verification - Group dependencies into version catalogue bundles --- build.gradle.kts | 51 +++------ gradle/libs.versions.toml | 88 +++++++++++++++ .../DeltaForceGuideApplication.java | 2 + .../deltaforceguide/client/EmailClient.java | 105 ++++++++++++++++++ .../config/EmailTemplateConfig.java | 37 ++++++ .../controller/AuthController.java | 11 +- .../domain/dto/RegisterRequest.java | 4 +- .../dto/SendVerificationCodeRequest.java | 9 ++ .../dto/SendVerificationCodeResponse.java | 6 + .../domain/dto/UpdateProfileRequest.java | 6 +- .../domain/dto/UserProfileResponse.java | 2 - .../deltaforceguide/domain/entity/User.java | 18 --- .../exeption/ConflictException.java | 11 ++ .../repository/BaseRepository.java | 7 -- .../repository/FirearmRepository.java | 3 +- .../repository/ModificationRepository.java | 3 +- .../repository/UserRepository.java | 3 +- .../repository/UserRoleRepository.java | 7 +- .../deltaforceguide/service/AuthService.java | 69 +++++++++++- .../deltaforceguide/service/UserService.java | 5 +- src/main/resources/application.yaml | 4 + .../db/migration/V6__user_profile.sql | 1 - .../mail-templates/verification-code.html | 71 ++++++++++++ 23 files changed, 440 insertions(+), 83 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java create mode 100644 src/main/resources/mail-templates/verification-code.html diff --git a/build.gradle.kts b/build.gradle.kts index 8e62389..7dac164 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,43 +24,28 @@ java { } dependencies { - implementation(libs.jspecify.core) + // BOMs are imported as platforms, so they cannot be bundled. implementation(platform(libs.aws.sdk.bom)) - implementation(libs.aws.sdk.s3) - implementation(libs.commons.io) - implementation(libs.commons.lang) - implementation(libs.commons.collections) - implementation(libs.hypersistence.core) implementation(platform(libs.onixbyte.versionCatalogue)) - implementation(libs.onixbyte.tuple) - implementation(libs.onixbyte.commonToolbox) - implementation(libs.onixbyte.mathToolbox) - implementation(libs.onixbyte.identityGenerator) - implementation(libs.onixbyte.captcha) - implementation(libs.onixbyte.regions) - implementation(libs.onixbyte.cryptoToolbox) - implementation(libs.jwt.core) + + implementation(libs.bundles.utilities) + implementation(libs.bundles.onixbyte) + implementation(libs.bundles.web) + implementation(libs.bundles.persistence) + implementation(libs.bundles.security) + implementation(libs.bundles.cache) + implementation(libs.bundles.observability) + implementation(libs.bundles.mail) + implementation(libs.bundles.aws) + implementation(libs.bundles.docs) + + // Annotation processor for @ConfigurationProperties; kept out of bundles. implementation(libs.spring.boot.configurationProcessor) - implementation(libs.spring.boot.actuator) - implementation(libs.spring.boot.starter.web) - implementation(libs.spring.boot.starter.webFlux) - implementation(libs.spring.boot.starter.validation) - implementation(libs.spring.boot.starter.redis) - implementation(libs.spring.boot.starter.cache) - implementation(libs.spring.boot.starter.jpa) - implementation(libs.mybatis.starter.core) - implementation(libs.flyway.core) - implementation(libs.flyway.postgresql) - implementation(libs.jackson.jsr310) - implementation(libs.spring.boot.starter.doc) - implementation(libs.spring.boot.starter.security) - testImplementation(libs.spring.boot.starter.test) - testImplementation(libs.reactor.test) - testImplementation(libs.mybatis.starter.test) - testImplementation(libs.spring.security.test) + runtimeOnly(libs.postgres.driver) - testRuntimeOnly(libs.h2.database) - testRuntimeOnly(libs.junit.launcher) + + testImplementation(libs.bundles.testing) + testRuntimeOnly(libs.bundles.testRuntime) } tasks.processResources { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9650f6..a9ff6c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,11 @@ spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" } jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" } +# Java Mail & Templating +spring-boot-starter-mail = { module = "org.springframework.boot:spring-boot-starter-mail" } +spring-boot-starter-thymeleaf = { group = "org.springframework.boot", name = "spring-boot-starter-thymeleaf" } +thymeleaf-extras-springsecurity6 = { group = "org.thymeleaf.extras", name = "thymeleaf-extras-springsecurity6" } + # Spring Doc spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" } @@ -73,6 +78,89 @@ reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" } mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" } +[bundles] +# General utilities +utilities = [ + "jspecify-core", + "commons-io", + "commons-lang", + "commons-collections", + "jackson-jsr310", +] + +# Onixbyte ecosystem (the version-catalogue BOM stays out of bundles, it is imported as a platform) +onixbyte = [ + "onixbyte-tuple", + "onixbyte-commonToolbox", + "onixbyte-cryptoToolbox", + "onixbyte-mathToolbox", + "onixbyte-identityGenerator", + "onixbyte-captcha", + "onixbyte-regions", +] + +# Web layer +web = [ + "spring-boot-starter-web", + "spring-boot-starter-webFlux", + "spring-boot-starter-validation", +] + +# Persistence and database +persistence = [ + "spring-boot-starter-jpa", + "mybatis-starter-core", + "hypersistence-core", + "flyway-core", + "flyway-postgresql", +] + +# Security and authentication +security = [ + "spring-boot-starter-security", + "jwt-core", +] + +# Caching (Redis) +cache = [ + "spring-boot-starter-redis", + "spring-boot-starter-cache", +] + +# Observability +observability = [ + "spring-boot-actuator", +] + +# Mail and templating +mail = [ + "spring-boot-starter-mail", + "spring-boot-starter-thymeleaf", + "thymeleaf-extras-springsecurity6", +] + +# AWS cloud services +aws = [ + "aws-sdk-s3", +] + +# API documentation +docs = [ + "spring-boot-starter-doc", +] + +# Testing +testing = [ + "spring-boot-starter-test", + "spring-security-test", + "reactor-test", + "mybatis-starter-test", +] +testRuntime = [ + "h2-database", + "junit-launcher", +] + [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" } diff --git a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java index a23fb88..c500f7d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java @@ -2,12 +2,14 @@ package com.onixbyte.deltaforceguide; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; /** * Entry point for the Delta Force Guide Server application. * * @author zihluwang */ +@EnableAsync @SpringBootApplication public class DeltaForceGuideApplication { diff --git a/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java b/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java new file mode 100644 index 0000000..52a772f --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java @@ -0,0 +1,105 @@ +package com.onixbyte.deltaforceguide.client; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Component +public class EmailClient { + + private static final Logger log = LoggerFactory.getLogger(EmailClient.class); + + private final JavaMailSender mailSender; + + private final TemplateEngine emailTemplateEngine; + + @Value("${spring.mail.username}") + private String fromAddress; + + @Autowired + public EmailClient( + JavaMailSender mailSender, + TemplateEngine emailTemplateEngine + ) { + this.mailSender = mailSender; + this.emailTemplateEngine = emailTemplateEngine; + } + + /** + * Send a template-based HTML email asynchronously. + * + * @param toRecipient recipient email address + * @param subject subject line + * @param templateName template name (without .html extension, under mail-templates/) + * @param variables context variables required by the template + */ + @Async + public void sendTemplateMail(String toRecipient, String subject, String templateName, Map variables) { + try { + // Render HTML content using the dedicated email template engine + var context = new Context(); + if (variables != null && !variables.isEmpty()) { + context.setVariables(variables); + } + var htmlBody = emailTemplateEngine.process(templateName, context); + + log.debug("htmlBody={}", htmlBody); + + // Build the MIME message + var message = mailSender.createMimeMessage(); + var helper = new MimeMessageHelper( + message, + MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED, + StandardCharsets.UTF_8.name() + ); + + helper.setFrom(new InternetAddress(fromAddress, "阿萨拉向导", StandardCharsets.UTF_8.name())); + helper.setTo(toRecipient); + helper.setSubject(subject); + helper.setText(htmlBody, true); + + // Dispatch the message + mailSender.send(message); + log.info("Successfully dispatched email to: [{}] with subject: [{}]", toRecipient, subject); + + } catch (MessagingException | UnsupportedEncodingException ex) { + log.error("Failed to construct or send email to: [{}], subject: [{}]", toRecipient, subject, ex); + // Handle or rethrow custom business exception if required + } catch (Exception ex) { + log.error("Unexpected error occurred while processing email for: [{}]", toRecipient, ex); + } + } + + /** + * Convenience method specifically for sending registration/login verification codes. + * + * @param toRecipient target recipient email + * @param username display username + * @param code verification code string + * @param expireMinutes expiration time in minutes + */ + @Async + public void sendVerificationCode(String toRecipient, String username, String code, int expireMinutes) { + var subject = "【阿萨拉向导】账号注册验证码"; + var variables = Map.of( + "username", username, + "code", code, + "expireMinutes", expireMinutes + ); + + sendTemplateMail(toRecipient, subject, "verification-code", variables); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java new file mode 100644 index 0000000..cf713eb --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java @@ -0,0 +1,37 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.captcha.text.TextProducer; +import com.onixbyte.captcha.text.impl.DefaultTextProducer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; +import org.thymeleaf.templateresolver.ITemplateResolver; + +import java.nio.charset.StandardCharsets; + +@Configuration +public class EmailTemplateConfig { + + @Bean + public TemplateEngine emailTemplateEngine() { + var resolver = new ClassLoaderTemplateResolver(); + // Template files are located under src/main/resources/mail-templates/ + resolver.setPrefix("mail-templates/"); + resolver.setSuffix(".html"); + resolver.setTemplateMode(TemplateMode.HTML); + resolver.setCharacterEncoding(StandardCharsets.UTF_8.name()); + resolver.setCacheable(true); // Enable caching for the production environment + + var templateEngine = new SpringTemplateEngine(); + templateEngine.addTemplateResolver(resolver); + return templateEngine; + } + + @Bean + public TextProducer textProducer() { + return DefaultTextProducer.builder().build(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index e1071ed..c88c4bd 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -1,8 +1,6 @@ package com.onixbyte.deltaforceguide.controller; -import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; -import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; -import com.onixbyte.deltaforceguide.domain.dto.UserResponse; +import com.onixbyte.deltaforceguide.domain.dto.*; import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.service.AuthService; import io.swagger.v3.oas.annotations.Operation; @@ -48,6 +46,13 @@ public class AuthController { .build(); } + @Operation(description = "发送邮件验证码") + @PostMapping("/verification-code") + public SendVerificationCodeResponse sendVerificationCode(@Validated @RequestBody SendVerificationCodeRequest request) { + var verificationCodeId = authService.sendVerificationCode(request); + return new SendVerificationCodeResponse(verificationCodeId); + } + @Operation(description = "用户注册") @PostMapping("/register") public ResponseEntity register(@Validated @RequestBody RegisterRequest request) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java index 92b8040..fe314f4 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java @@ -24,6 +24,8 @@ public record RegisterRequest( String avatarUrl, @NotBlank(message = "密码不能为空") @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) - String password + String password, + @NotBlank(message = "验证码 ID 不能为空") String verificationCodeId, + @NotBlank(message = "验证码不能为空") String verificationCode ) { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java new file mode 100644 index 0000000..e2eb2f4 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SendVerificationCodeRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "电子邮箱地址不能为空") String email +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java new file mode 100644 index 0000000..2bc9c51 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record SendVerificationCodeResponse( + String verificationCodeId +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java index 0d1b016..211e3b3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java @@ -10,9 +10,5 @@ import jakarta.validation.constraints.Size; */ public record UpdateProfileRequest( @Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间") - String nickname, - - @Email(message = "请输入正确的电子邮箱地址") - @Size(max = 255, message = "电子邮箱长度不能超过255个字符") - String email + String nickname ) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java index 5609119..7e40211 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java @@ -16,7 +16,6 @@ public record UserProfileResponse( String nickname, String email, String avatarUrl, - Boolean emailVerified, Boolean enabled, LocalDateTime createdAt, List roles @@ -37,7 +36,6 @@ public record UserProfileResponse( ? user.getUsername() : user.getNickname(), user.getEmail(), user.getAvatarUrl(), - user.getEmailVerified(), user.getEnabled(), user.getCreatedAt(), roles diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java index c061c09..de7cad5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -39,9 +39,6 @@ public class User { @Column(name = "avatar_url") private String avatarUrl; - @Column(name = "email_verified", nullable = false) - private Boolean emailVerified = false; - @Column(name = "enabled", nullable = false) private Boolean enabled = true; @@ -94,14 +91,6 @@ public class User { this.avatarUrl = avatarUrl; } - public Boolean getEmailVerified() { - return emailVerified; - } - - public void setEmailVerified(Boolean emailVerified) { - this.emailVerified = emailVerified; - } - public Boolean getEnabled() { return enabled; } @@ -160,7 +149,6 @@ public class User { private String email; private String nickname; private String avatarUrl; - private Boolean emailVerified; private Boolean enabled; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -191,11 +179,6 @@ public class User { return this; } - public Builder emailVerified(Boolean emailVerified) { - this.emailVerified = emailVerified; - return this; - } - public Builder enabled(Boolean enabled) { this.enabled = enabled; return this; @@ -224,7 +207,6 @@ public class User { user.nickname = this.nickname == null || this.nickname.isBlank() ? this.username : this.nickname; user.avatarUrl = this.avatarUrl; - user.emailVerified = this.emailVerified != null && this.emailVerified; user.enabled = this.enabled == null || this.enabled; user.createdAt = this.createdAt; user.updatedAt = this.updatedAt; diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java new file mode 100644 index 0000000..971740f --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java @@ -0,0 +1,11 @@ +package com.onixbyte.deltaforceguide.exeption; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class ConflictException extends ResponseStatusException { + + public ConflictException(String reason) { + super(HttpStatus.CONFLICT, reason); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java deleted file mode 100644 index 93ab920..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.onixbyte.deltaforceguide.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; - -public interface BaseRepository extends JpaRepository, JpaSpecificationExecutor { -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index 881ee97..0e51b9c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; import java.util.List; @@ -15,7 +16,7 @@ import java.util.List; * @author zihluwang */ @Repository -public interface FirearmRepository extends BaseRepository { +public interface FirearmRepository extends JpaRepository, JpaSpecificationExecutor { Page findAllByType(FirearmType type, Pageable pageable); diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index bd16813..73173f9 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -20,7 +21,7 @@ import java.util.Optional; * @author zihluwang */ @Repository -public interface ModificationRepository extends BaseRepository { +public interface ModificationRepository extends JpaRepository, JpaSpecificationExecutor { @EntityGraph(attributePaths = {"firearm"}) Page findAllBy(Pageable pageable); diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java index cf81ca8..dad3df7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java @@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.User; import org.jspecify.annotations.NonNull; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -16,7 +17,7 @@ import java.util.Optional; * @author zihluwang */ @Repository -public interface UserRepository extends BaseRepository { +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { @Override @EntityGraph(attributePaths = {"credentials"}) diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java index a227b72..16f0792 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java @@ -2,10 +2,7 @@ package com.onixbyte.deltaforceguide.repository; import com.onixbyte.deltaforceguide.domain.entity.UserRole; import com.onixbyte.deltaforceguide.domain.entity.UserRoleId; -import org.springframework.data.jpa.repository.EntityGraph; -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.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -17,7 +14,7 @@ import java.util.List; * @author zihluwang */ @Repository -public interface UserRoleRepository extends BaseRepository { +public interface UserRoleRepository extends JpaRepository, JpaSpecificationExecutor { /** * Find all roles assigned to a given user. diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index 82ffbb0..4349d36 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -1,12 +1,16 @@ package com.onixbyte.deltaforceguide.service; +import com.onixbyte.captcha.text.TextProducer; +import com.onixbyte.deltaforceguide.client.EmailClient; import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; +import com.onixbyte.deltaforceguide.domain.dto.SendVerificationCodeRequest; import com.onixbyte.deltaforceguide.domain.dto.UserResponse; import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.exeption.BadRequestException; +import com.onixbyte.deltaforceguide.exeption.ConflictException; import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.manager.CookieManager; import com.onixbyte.deltaforceguide.manager.UserManager; @@ -17,6 +21,8 @@ import com.onixbyte.deltaforceguide.shared.Role; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -26,6 +32,7 @@ import org.springframework.stereotype.Service; import java.time.Duration; import java.time.LocalDateTime; +import java.util.UUID; /** * Service handling user authentication, login, and session management. @@ -41,19 +48,31 @@ public class AuthService { private final CookieManager cookieManager; private final UserManager userManager; private final PasswordEncoder passwordEncoder; + private final TextProducer textProducer; + private final CacheManager cacheManager; + private final RedisTemplate redisTemplate; + private final EmailClient emailClient; public AuthService( AuthenticationManager authenticationManager, TokenClient tokenClient, CookieManager cookieManager, UserManager userManager, - PasswordEncoder passwordEncoder + PasswordEncoder passwordEncoder, + TextProducer textProducer, + CacheManager cacheManager, + RedisTemplate redisTemplate, + EmailClient emailClient ) { this.authenticationManager = authenticationManager; this.tokenClient = tokenClient; this.cookieManager = cookieManager; this.userManager = userManager; this.passwordEncoder = passwordEncoder; + this.textProducer = textProducer; + this.cacheManager = cacheManager; + this.redisTemplate = redisTemplate; + this.emailClient = emailClient; } /** @@ -96,6 +115,19 @@ public class AuthService { throw new BadRequestException("该邮箱已被注册。"); } + var code = request.verificationCode(); + var verificationCodeId = request.verificationCodeId(); + + var storedCode = redisTemplate.opsForValue().get("verification-code:" + verificationCodeId); + if (!(storedCode instanceof String strStoredCode)) { + log.error("storedCode not a String, storedCode={}", storedCode); + throw new InternalServerErrorException("系统内部错误,请重试"); + } + + if (!code.equalsIgnoreCase(strStoredCode)) { + throw new BadRequestException("邮箱验证码不正确,请重试。"); + } + var now = LocalDateTime.now(); var user = User.builder() .username(username) @@ -129,4 +161,39 @@ public class AuthService { public HttpCookie logout() { return cookieManager.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO); } + + /** + * Generates and dispatches a verification code to the given email address. + *

+ * The code is cached under the returned UUID for 10 minutes and emailed + * asynchronously to the recipient. The UUID must be supplied back to the + * server when verifying the code. + * + * @param request the verification code request containing the target username and email + * @return the UUID identifying the verification record, to be used when verifying the code + * @throws ConflictException if the username or email is already registered + */ + public String sendVerificationCode(SendVerificationCodeRequest request) { + // Check username. + if (userManager.existsByUsername(request.username())) { + throw new ConflictException("用户名【" + request.username() + "】已被使用。"); + } + + // Check email address. + if (userManager.existsByEmail(request.email())) { + throw new ConflictException("邮箱地址【" + request.email() + "】已被使用。"); + } + + // Generate UUID and verification code. + var uuid = UUID.randomUUID().toString().replace("-", ""); + var code = textProducer.getText(); + + // Save verification code to cache. + redisTemplate.opsForValue().set("verification-code:" + uuid, code, Duration.ofMinutes(10L)); + + // Send email. + emailClient.sendVerificationCode(request.email(), request.username(), code, 10); + + return uuid; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java index cabefe7..d9b9043 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -196,10 +196,7 @@ public class UserService { if (StringUtils.hasText(request.nickname())) { user.setNickname(request.nickname()); } - if (StringUtils.hasText(request.email()) && !Objects.equals(user.getEmail(), request.email())) { - user.setEmail(request.email()); - user.setEmailVerified(false); - } + userManager.save(user); return getProfile(user); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8fcdf2f..cf3b3e8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -30,6 +30,8 @@ spring: flyway: enabled: true baseline-on-migrate: true + thymeleaf: + enabled: false mybatis: configuration: @@ -40,6 +42,8 @@ mybatis: mapper-locations: classpath:/mapper/*.xml logging: + pattern: + dateformat: dd MMM yyyy HH:mm:ss level: org.hibernate: orm.connections.pooling: off diff --git a/src/main/resources/db/migration/V6__user_profile.sql b/src/main/resources/db/migration/V6__user_profile.sql index 6bb1ff8..4a4dbc5 100644 --- a/src/main/resources/db/migration/V6__user_profile.sql +++ b/src/main/resources/db/migration/V6__user_profile.sql @@ -1,7 +1,6 @@ ALTER TABLE app_user ADD COLUMN nickname VARCHAR(64), ADD COLUMN avatar_url VARCHAR(512), - ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE, ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); diff --git a/src/main/resources/mail-templates/verification-code.html b/src/main/resources/mail-templates/verification-code.html new file mode 100644 index 0000000..d6e9c37 --- /dev/null +++ b/src/main/resources/mail-templates/verification-code.html @@ -0,0 +1,71 @@ + + + + + + 账号验证 + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+

账号注册验证

+
+
+
+

+ 您好 用户, +

+

+ 感谢注册我们的服务。请使用下方的验证码完成账号验证,该验证码在 10 分钟 内有效: +

+ + + + + + +
+ 123456 +
+ +

+ 若您未发起此操作或未尝试注册该账号,请直接忽略本邮件。 +

+
+

+ 此邮件为系统自动发送,请勿直接回复。 +

+

+ © 2026 阿萨拉向导. 保留所有权利。 +

+
+
+ + \ No newline at end of file From 4d9bf64ef49900dad453dc6b46752d49a7a90caf Mon Sep 17 00:00:00 2001 From: zihluwang Date: Wed, 19 Aug 2026 14:10:41 +0800 Subject: [PATCH 110/117] fix(mail): add base smtp config for mail sender - Configure a default SMTP host and username so the JavaMailSender bean is always created - Move from-address injection from a field to a setter --- .../onixbyte/deltaforceguide/client/EmailClient.java | 10 +++++++--- src/main/resources/application.yaml | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java b/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java index 52a772f..fdf9bec 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java +++ b/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java @@ -26,9 +26,6 @@ public class EmailClient { private final TemplateEngine emailTemplateEngine; - @Value("${spring.mail.username}") - private String fromAddress; - @Autowired public EmailClient( JavaMailSender mailSender, @@ -38,6 +35,13 @@ public class EmailClient { this.emailTemplateEngine = emailTemplateEngine; } + private String fromAddress; + + @Autowired + public void setFromAddress(@Value("${spring.mail.username}") String fromAddress) { + this.fromAddress = fromAddress; + } + /** * Send a template-based HTML email asynchronously. * diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cf3b3e8..12b0093 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -32,6 +32,10 @@ spring: baseline-on-migrate: true thymeleaf: enabled: false + mail: + # This configuration is used to suppressing warnings of no beans found of JavaMailSender. + host: example.smtp.com + username: app@dfguide.onixbyte.cn mybatis: configuration: From 2936b6aeceed09ebc888cb3b05a841b381f86ac9 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 20 Aug 2026 15:18:49 +0800 Subject: [PATCH 111/117] feat(mod): add status and creator tracking - Introduce ModificationStatus enum with integer codes and a JPA converter - Record the creating user on new modifications - Rebuild the modification table with status, creator and audit columns - Simplify user_role to a single-column primary key --- .../controller/ModificationController.java | 16 +++- .../ModificationStatusConverter.java | 24 +++++ .../domain/entity/Modification.java | 94 +++++++++++++++++-- .../domain/entity/UserRole.java | 64 ++++--------- .../domain/entity/UserRoleId.java | 79 ---------------- .../enumeration/ModificationStatus.java | 44 +++++++++ .../manager/ModificationManager.java | 12 ++- .../repository/UserRoleRepository.java | 7 +- .../service/ModificationService.java | 11 ++- .../service/WebhookService.java | 4 +- .../db/migration/V8__modification.sql | 71 ++++++++++++++ 11 files changed, 276 insertions(+), 150 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/enumeration/ModificationStatus.java create mode 100644 src/main/resources/db/migration/V8__modification.sql diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index 7eaf94c..8c68b87 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -4,7 +4,9 @@ import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchCreateRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; +import com.onixbyte.deltaforceguide.security.resolver.CurrentUser; import com.onixbyte.deltaforceguide.service.ModificationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -66,15 +68,21 @@ public class ModificationController { @RequiresAuth @Operation(description = "创建改装") @PostMapping - public ModificationResponse create(@Valid @RequestBody ModificationRequest request) { - return modificationService.create(request); + public ModificationResponse create( + @CurrentUser User user, + @Valid @RequestBody ModificationRequest request + ) { + return modificationService.create(request, user); } @RequiresAuth @Operation(description = "批量创建改装") @PostMapping("/batch") - public List batchCreate(@Valid @RequestBody ModificationBatchCreateRequest request) { - return modificationService.batchCreate(request.modifications()); + public List batchCreate( + @CurrentUser User user, + @Valid @RequestBody ModificationBatchCreateRequest request + ) { + return modificationService.batchCreate(request.modifications(), user); } @RequiresAuth diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java new file mode 100644 index 0000000..5e5bab5 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java @@ -0,0 +1,24 @@ +package com.onixbyte.deltaforceguide.domain.converter; + +import com.onixbyte.deltaforceguide.enumeration.ModificationStatus; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA attribute converter that maps {@link ModificationStatus} enum to/from its integer database representation. + * + * @author zihluwang + */ +@Converter +public class ModificationStatusConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(ModificationStatus attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public ModificationStatus convertToEntityAttribute(Integer dbData) { + return ModificationStatus.fromCode(dbData); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java index a1942f7..9780741 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java @@ -1,11 +1,15 @@ package com.onixbyte.deltaforceguide.domain.entity; +import com.onixbyte.deltaforceguide.domain.converter.ModificationStatusConverter; +import com.onixbyte.deltaforceguide.enumeration.ModificationStatus; import io.hypersistence.utils.hibernate.type.json.JsonType; import jakarta.persistence.*; import org.hibernate.annotations.Type; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Optional; /** * Entity representing a firearm modification or build configuration. @@ -14,10 +18,10 @@ import java.util.List; */ @Entity @Table( - name = "modification", - indexes = { - @Index(name = "idx_modification_firearm_id", columnList = "firearm_id") - } + name = "modification", + indexes = { + @Index(name = "idx_modification_firearm_id", columnList = "firearm_id") + } ) public class Modification { @@ -25,6 +29,10 @@ public class Modification { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "firearm_id", nullable = false, foreignKey = @ForeignKey(name = "fk_modification_firearm")) private Firearm firearm; @@ -36,11 +44,11 @@ public class Modification { private String code; @Type(JsonType.class) - @Column(name = "tags", columnDefinition = "jsonb") + @Column(name = "tags", columnDefinition = "jsonb", nullable = false) private List tags = new ArrayList<>(); @Type(JsonType.class) - @Column(name = "accessories", columnDefinition = "jsonb") + @Column(name = "accessories", columnDefinition = "jsonb", nullable = false) private List accessories = new ArrayList<>(); @Column(name = "note", columnDefinition = "TEXT") @@ -52,6 +60,16 @@ public class Modification { @Column(name = "video_url", length = 512) private String videoUrl; + @Column(name = "status", nullable = false) + @Convert(converter = ModificationStatusConverter.class) + private ModificationStatus status = ModificationStatus.DRAFT; + + @Column(name = "create_by") + private Long createBy; + + @Column(name = "create_time", nullable = false) + private OffsetDateTime createTime; + public Long getId() { return id; } @@ -132,6 +150,38 @@ public class Modification { this.videoUrl = videoUrl; } + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Long getCreateBy() { + return createBy; + } + + public void setCreateBy(Long createBy) { + this.createBy = createBy; + } + + public OffsetDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(OffsetDateTime createTime) { + this.createTime = createTime; + } + + public ModificationStatus getStatus() { + return status; + } + + public void setStatus(ModificationStatus status) { + this.status = status; + } + public static Builder builder() { return new Builder(); } @@ -139,6 +189,7 @@ public class Modification { public static class Builder { private Long id; + private User user; private Firearm firearm; private String name; private String code; @@ -147,12 +198,20 @@ public class Modification { private String note; private String author; private String videoUrl; + private Long createBy; + private OffsetDateTime createTime; + private ModificationStatus status; public Builder id(Long id) { this.id = id; return this; } + public Builder user(User user) { + this.user = user; + return this; + } + public Builder firearm(Firearm firearm) { this.firearm = firearm; return this; @@ -193,17 +252,36 @@ public class Modification { return this; } + public Builder createBy(Long createBy) { + this.createBy = createBy; + return this; + } + + public Builder createTime(OffsetDateTime createTime) { + this.createTime = createTime; + return this; + } + + public Builder status(ModificationStatus status) { + this.status = status; + return this; + } + public Modification build() { Modification modification = new Modification(); modification.id = this.id; + modification.user = this.user; modification.firearm = this.firearm; modification.name = this.name; modification.code = this.code; - modification.tags = this.tags == null ? new ArrayList<>() : this.tags; - modification.accessories = this.accessories == null ? new ArrayList<>() : this.accessories; + modification.tags = Optional.ofNullable(this.tags).orElseGet(ArrayList::new); + modification.accessories = Optional.ofNullable(this.accessories).orElseGet(ArrayList::new); modification.note = this.note; modification.author = this.author; modification.videoUrl = this.videoUrl; + modification.createBy = this.createBy; + modification.createTime = Optional.ofNullable(this.createTime).orElseGet(OffsetDateTime::now); + modification.status = Optional.ofNullable(this.status).orElse(ModificationStatus.DRAFT); return modification; } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java index 8caf84e..07bb6dc 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java @@ -1,12 +1,10 @@ package com.onixbyte.deltaforceguide.domain.entity; -import jakarta.persistence.AttributeOverride; -import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; -import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.ForeignKey; +import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.MapsId; @@ -14,6 +12,8 @@ import jakarta.persistence.Table; /** * Entity representing a role assignment linking a user to a security role. + *

+ * A user owns at most one role assignment, so the user ID is the primary key. * * @author zihluwang */ @@ -21,24 +21,24 @@ import jakarta.persistence.Table; @Table(name = "user_role") public class UserRole { - @EmbeddedId - @AttributeOverrides({ - @AttributeOverride(name = "userId", column = @Column(name = "user_id")), - @AttributeOverride(name = "role", column = @Column(name = "role")) - }) - private UserRoleId id = new UserRoleId(); + @Id + @Column(name = "user_id") + private Long userId; @MapsId("userId") @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_role_user")) private User user; - public UserRoleId getId() { - return id; + @Column(name = "role", nullable = false, length = 32) + private String role; + + public Long getUserId() { + return userId; } - public void setId(UserRoleId id) { - this.id = id; + public void setUserId(Long userId) { + this.userId = userId; } public User getUser() { @@ -47,32 +47,17 @@ public class UserRole { public void setUser(User user) { this.user = user; - if (this.id == null) { - this.id = new UserRoleId(); + if (user != null) { + this.userId = user.getId(); } - this.id.setUserId(user == null ? null : user.getId()); - } - - public Long getUserId() { - return id == null ? null : id.getUserId(); - } - - public void setUserId(Long userId) { - if (this.id == null) { - this.id = new UserRoleId(); - } - this.id.setUserId(userId); } public String getRole() { - return id == null ? null : id.getRole(); + return role; } public void setRole(String role) { - if (this.id == null) { - this.id = new UserRoleId(); - } - this.id.setRole(role); + this.role = role; } public static Builder builder() { @@ -81,16 +66,10 @@ public class UserRole { public static class Builder { - private UserRoleId id; private User user; private Long userId; private String role; - public Builder id(UserRoleId id) { - this.id = id; - return this; - } - public Builder user(User user) { this.user = user; return this; @@ -108,16 +87,13 @@ public class UserRole { public UserRole build() { UserRole userRole = new UserRole(); - userRole.id = this.id == null ? new UserRoleId() : this.id; userRole.user = this.user; + userRole.role = this.role; if (this.user != null) { - userRole.id.setUserId(this.user.getId()); + userRole.userId = this.user.getId(); } if (this.userId != null) { - userRole.id.setUserId(this.userId); - } - if (this.role != null) { - userRole.id.setRole(this.role); + userRole.userId = this.userId; } return userRole; } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java deleted file mode 100644 index 96f5b0e..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.onixbyte.deltaforceguide.domain.entity; - -import jakarta.persistence.Embeddable; - -import java.io.Serializable; -import java.util.Objects; - -/** - * Composite key for the UserRole entity, combining user ID and role name. - * - * @author zihluwang - */ -@Embeddable -public class UserRoleId implements Serializable { - - private Long userId; - - private String role; - - public Long getUserId() { - return userId; - } - - public void setUserId(Long userId) { - this.userId = userId; - } - - public String getRole() { - return role; - } - - public void setRole(String role) { - this.role = role; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - UserRoleId that = (UserRoleId) o; - return Objects.equals(userId, that.userId) && Objects.equals(role, that.role); - } - - @Override - public int hashCode() { - return Objects.hash(userId, role); - } - - public static Builder builder() { - return new Builder(); - } - - public static class Builder { - - private Long userId; - private String role; - - public Builder userId(Long userId) { - this.userId = userId; - return this; - } - - public Builder role(String role) { - this.role = role; - return this; - } - - public UserRoleId build() { - UserRoleId id = new UserRoleId(); - id.userId = this.userId; - id.role = this.role; - return id; - } - } -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/enumeration/ModificationStatus.java b/src/main/java/com/onixbyte/deltaforceguide/enumeration/ModificationStatus.java new file mode 100644 index 0000000..5699405 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/enumeration/ModificationStatus.java @@ -0,0 +1,44 @@ +package com.onixbyte.deltaforceguide.enumeration; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Enumeration of modification statuses. + *

+ * Each status is associated with an integer code used for database persistence. + * + * @author zihluwang + */ +public enum ModificationStatus { + + DRAFT(0), + PENDING_APPROVAL(1), + PUBLISHED(2); + + private final int code; + + ModificationStatus(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static final Map CODE_MAP = Arrays.stream(values()) + .collect(Collectors.toMap(ModificationStatus::getCode, Function.identity())); + + /** + * Resolve a ModificationStatus from its integer code. + * + * @param code the integer code, may be null + * @return the corresponding ModificationStatus, or null if the code is null + * @throws IllegalArgumentException if the code does not match any known status + */ + public static ModificationStatus fromCode(Integer code) { + return CODE_MAP.get(code); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java index f7defab..8dfdc92 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java @@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Modification; import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; @@ -36,16 +37,16 @@ public class ModificationManager { } @Transactional - public ModificationResponse create(ModificationRequest request) { + public ModificationResponse create(ModificationRequest request, User user) { var firearm = firearmRepository.findById(request.firearmId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId())); - var modification = toEntity(request, firearm); + var modification = toEntity(request, firearm, user); return ModificationResponse.from(modificationRepository.save(modification)); } @Transactional - public List batchCreate(List requests) { + public List batchCreate(List requests, User user) { var firearmIds = requests.stream() .map(ModificationRequest::firearmId) .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); @@ -63,7 +64,7 @@ public class ModificationManager { } var modifications = requests.stream() - .map(req -> toEntity(req, firearmMap.get(req.firearmId()))) + .map(req -> toEntity(req, firearmMap.get(req.firearmId()), user)) .toList(); return modificationRepository.saveAll(modifications) .stream() @@ -86,7 +87,7 @@ public class ModificationManager { return matches.getFirst().getId(); } - private Modification toEntity(ModificationRequest request, Firearm firearm) { + private Modification toEntity(ModificationRequest request, Firearm firearm, User user) { return Modification.builder() .firearm(firearm) .name(request.name()) @@ -96,6 +97,7 @@ public class ModificationManager { .note(request.note()) .author(request.author()) .videoUrl(request.videoUrl()) + .createBy(user == null ? null : user.getId()) .build(); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java index 16f0792..62368b2 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java @@ -1,7 +1,6 @@ package com.onixbyte.deltaforceguide.repository; import com.onixbyte.deltaforceguide.domain.entity.UserRole; -import com.onixbyte.deltaforceguide.domain.entity.UserRoleId; import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -14,7 +13,7 @@ import java.util.List; * @author zihluwang */ @Repository -public interface UserRoleRepository extends JpaRepository, JpaSpecificationExecutor { +public interface UserRoleRepository extends JpaRepository, JpaSpecificationExecutor { /** * Find all roles assigned to a given user. @@ -41,7 +40,7 @@ public interface UserRoleRepository extends JpaRepository, select count(ur) > 0 from UserRole ur where ur.user.id = :userId - and ur.id.role = :role + and ur.role = :role """) boolean existsByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role); @@ -55,7 +54,7 @@ public interface UserRoleRepository extends JpaRepository, @Query(""" delete from UserRole ur where ur.user.id = :userId - and ur.id.role = :role + and ur.role = :role """) void deleteByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index 9b97e8f..d2f27c5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -8,6 +8,7 @@ import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; import com.onixbyte.deltaforceguide.domain.entity.Accessory; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Modification; +import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.Tuning; import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.repository.FirearmRepository; @@ -104,20 +105,22 @@ public class ModificationService { * Creates a new modification for a given firearm. * * @param request the modification creation request + * @param user the authenticated user creating the modification * @return the created modification response */ - public ModificationResponse create(ModificationRequest request) { - return modificationManager.create(request); + public ModificationResponse create(ModificationRequest request, User user) { + return modificationManager.create(request, user); } /** * Creates multiple modifications in a single batch operation. * * @param requests list of modification creation requests + * @param user the authenticated user creating the modifications * @return list of created modification responses */ - public List batchCreate(List requests) { - return modificationManager.batchCreate(requests); + public List batchCreate(List requests, User user) { + return modificationManager.batchCreate(requests, user); } /** diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java index 6b7810e..c922b9f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -88,7 +88,7 @@ public class WebhookService { private void processSingle(Long issueNumber, Map data) { var request = mapToRequest(data); log.info("Creating modification from issue #{}: name={}", issueNumber, request.name()); - modificationManager.create(request); + modificationManager.create(request, null); } @SuppressWarnings("unchecked") @@ -102,7 +102,7 @@ public class WebhookService { .map(this::mapToRequest) .toList(); log.info("Batch creating {} modifications from issue #{}", requests.size(), issueNumber); - modificationManager.batchCreate(requests); + modificationManager.batchCreate(requests, null); } private ModificationRequest mapToRequest(Map data) { diff --git a/src/main/resources/db/migration/V8__modification.sql b/src/main/resources/db/migration/V8__modification.sql new file mode 100644 index 0000000..eba22e7 --- /dev/null +++ b/src/main/resources/db/migration/V8__modification.sql @@ -0,0 +1,71 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS modification_new +( + id BIGSERIAL NOT NULL PRIMARY KEY, + user_id BIGINT NULL REFERENCES app_user (id), + firearm_id BIGINT NOT NULL REFERENCES firearm (id), + name VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + tags JSONB NOT NULL DEFAULT '[]'::JSONB, + note TEXT NULL, + author VARCHAR(64) NULL, + video_url VARCHAR(512) NULL, + accessories JSONB NOT NULL DEFAULT '[]'::JSONB, + status INTEGER NOT NULL DEFAULT 0, + create_by BIGINT NULL, + create_time TIMESTAMPTZ NOT NULL, + CONSTRAINT tags_must_be_string_array CHECK ( + jsonb_typeof(tags) = 'array' AND + NOT jsonb_path_exists(tags, '$[*] ? (@.type() != "string")') + ) +); + +DO +$$ + DECLARE + v_super_user_id BIGINT; + BEGIN + -- Added explicit table reference and LIMIT 1 to prevent multiple-row errors + SELECT app_user.id + INTO v_super_user_id + FROM app_user + LEFT JOIN user_role ur ON app_user.id = ur.user_id + WHERE ur.role = 'SUPER_USER' + LIMIT 1; + + -- Explicitly check for NULL rather than evaluating the integer + IF v_super_user_id IS NOT NULL THEN + INSERT INTO modification_new(id, user_id, firearm_id, name, code, tags, note, author, + video_url, accessories, status, create_by, create_time) + SELECT m.id, + NULL, + m.firearm_id, + m.name, + m.code, + COALESCE(m.tags, '[]'::JSONB), + m.note, + m.author, + m.video_url, + COALESCE(m.accessories, '[]'::JSONB), + 2, + v_super_user_id, + now() + FROM modification m; + ELSE + -- Fixed RAISE NOTICE parameter formatting + RAISE NOTICE 'Super user not found. Please execute the manual SQL commands after adding a super user.'; + END IF; + END +$$; + +ALTER INDEX idx_modification_firearm_id RENAME TO idx_modification_lagacy_firearm_id; + +CREATE INDEX idx_modification_firearm_id ON modification_new (firearm_id); + +ALTER TABLE modification + RENAME TO modification_legacy; +ALTER TABLE modification_new + RENAME TO modification; + +COMMIT; \ No newline at end of file From 00a62de5ee9ff4e3dc4c91cb225dcf14d745ab00 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Thu, 20 Aug 2026 15:43:03 +0800 Subject: [PATCH 112/117] refactor: retrieving value with Optional --- .../domain/converter/ModificationStatusConverter.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java index 5e5bab5..9da5368 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/ModificationStatusConverter.java @@ -4,6 +4,8 @@ import com.onixbyte.deltaforceguide.enumeration.ModificationStatus; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +import java.util.Optional; + /** * JPA attribute converter that maps {@link ModificationStatus} enum to/from its integer database representation. * @@ -14,7 +16,9 @@ public class ModificationStatusConverter implements AttributeConverter Date: Thu, 20 Aug 2026 17:24:06 +0800 Subject: [PATCH 113/117] fix(auth): query credentials by composite key Build a UserCredentialId and compare the embedded id directly, since provider and userId are not top-level attributes of UserCredential. --- .../UsernamePasswordAuthenticationProvider.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java index af29ba1..e979aa0 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -1,5 +1,6 @@ package com.onixbyte.deltaforceguide.security.provider; +import com.onixbyte.deltaforceguide.domain.entity.UserCredentialId; import com.onixbyte.deltaforceguide.domain.entity.UserRole; import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.exeption.UnauthorisedException; @@ -9,7 +10,6 @@ import com.onixbyte.deltaforceguide.repository.UserRoleRepository; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; import com.onixbyte.deltaforceguide.shared.CredentialProvider; import com.onixbyte.deltaforceguide.shared.Role; -import jakarta.persistence.criteria.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -20,7 +20,6 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; -import java.util.ArrayList; import java.util.List; /** @@ -70,13 +69,13 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro var user = userContainer.get(); + var userCredentialId = UserCredentialId.builder() + .provider(CredentialProvider.LOCAL) + .userId(user.getId()) + .build(); + // get userContainer credentials from database - var userCredentials = userCredentialRepository.findOne((root, query, cb) -> { - var predicates = new ArrayList(); - predicates.add(cb.equal(root.get("provider"), CredentialProvider.LOCAL)); - predicates.add(cb.equal(root.get("userId"), user.getId())); - return cb.and(predicates.toArray(Predicate[]::new)); - }) + var userCredentials = userCredentialRepository.findOne((root, query, cb) -> cb.equal(root.get("id"), userCredentialId)) .orElseThrow(() -> new UnauthorisedException("您还没有配置密码,请联系管理员处理。")); // validate password From 3aaf7feb4fdabd5f91ca304d07f9da8e2eafeb0f Mon Sep 17 00:00:00 2001 From: siujamo Date: Thu, 20 Aug 2026 17:24:08 +0800 Subject: [PATCH 114/117] refactor(mod): page query via specifications Unify the native JSONB query and findAllBy dual path through JpaSpecificationExecutor: - add ModificationSpecification for firearm and tag filters - eagerly fetch the firearm association via entity graph - filter by the firearm id attribute instead of a missing top-level field --- .../manager/ModificationManager.java | 8 ++++ .../repository/ModificationRepository.java | 31 +++----------- .../service/ModificationService.java | 29 ++++++------- .../ModificationSpecification.java | 41 +++++++++++++++++++ 4 files changed, 69 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/specification/ModificationSpecification.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java index 8dfdc92..5a5d618 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java @@ -11,6 +11,9 @@ import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -129,4 +132,9 @@ public class ModificationManager { tuning.setTuningValue(request.tuningValue()); return tuning; } + + @Transactional(readOnly = true) + public Page findBySpec(Specification spec, Pageable pageable) { + return modificationRepository.findAll(spec, pageable); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index 73173f9..b8a824d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification; import org.jspecify.annotations.NonNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -16,45 +17,23 @@ import java.util.Optional; /** * Spring Data JPA repository for {@link Modification} entity operations, - * including native JSONB tag filtering for Postgres. + * including JSONB tag filtering for Postgres via the specification API. * * @author zihluwang */ @Repository public interface ModificationRepository extends JpaRepository, JpaSpecificationExecutor { + @Override @EntityGraph(attributePaths = {"firearm"}) - Page findAllBy(Pageable pageable); - - @EntityGraph(attributePaths = {"firearm"}) - Page findAllByFirearm_Id(Long firearmId, Pageable pageable); + @NonNull + Page findAll(Specification spec, @NonNull Pageable pageable); @Override @EntityGraph(attributePaths = {"firearm"}) @NonNull Optional findById(@NonNull Long id); - /** - * Page query modifications with optional firearm and JSONB tag filtering. - * - * @param firearmId optional firearm ID filter (nullable) - * @param tagsJson optional JSON array of tags to match via Postgres {@code @>} operator (nullable) - * @param pageable pagination information - * @return a page of matching modifications - */ - @Query(value = """ - SELECT * FROM modification m - WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) - AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) - """, - countQuery = """ - SELECT count(*) FROM modification m - WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) - AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) - """, - nativeQuery = true) - Page pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable); - /** * Retrieve all distinct tag values from modifications, optionally filtered by firearm. * diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index d2f27c5..de63bee 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -1,5 +1,7 @@ package com.onixbyte.deltaforceguide.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; @@ -8,15 +10,14 @@ import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; import com.onixbyte.deltaforceguide.domain.entity.Accessory; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Modification; -import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.data.domain.Page; +import com.onixbyte.deltaforceguide.specification.ModificationSpecification; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -55,8 +56,8 @@ public class ModificationService { * Queries modifications with optional firearm and tag filters. * * @param firearmId optional firearm ID filter - * @param tags optional tag list filter - * @param pageable pagination parameters + * @param tags optional tag list filter + * @param pageable pagination parameters * @return a paginated response of modification records */ public PageResponse pageQuery(Long firearmId, List tags, Pageable pageable) { @@ -68,13 +69,13 @@ public class ModificationService { throw new RuntimeException("Failed to serialize tags", e); } } - - Page page; - if (tagsJson != null || firearmId != null) { - page = modificationRepository.pageQueryByFirearmAndTags(firearmId, tagsJson, pageable); - } else { - page = modificationRepository.findAllBy(pageable); - } + + var spec = Specification.allOf( + ModificationSpecification.hasFirearmId(firearmId), + ModificationSpecification.containsTags(tagsJson) + ); + + var page = modificationManager.findBySpec(spec, pageable); return PageResponse.from(page.map(ModificationResponse::from)); } @@ -126,7 +127,7 @@ public class ModificationService { /** * Updates an existing modification identified by ID. * - * @param id the modification ID + * @param id the modification ID * @param request the updated modification data * @return the updated modification response */ diff --git a/src/main/java/com/onixbyte/deltaforceguide/specification/ModificationSpecification.java b/src/main/java/com/onixbyte/deltaforceguide/specification/ModificationSpecification.java new file mode 100644 index 0000000..17799fd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/specification/ModificationSpecification.java @@ -0,0 +1,41 @@ +package com.onixbyte.deltaforceguide.specification; + +import com.onixbyte.deltaforceguide.domain.entity.Modification; +import org.springframework.data.jpa.domain.Specification; + +import java.util.Objects; + +public class ModificationSpecification { + + /** + * Filters by firearm ID if specified. + */ + public static Specification hasFirearmId(Long firearmId) { + return (root, query, cb) -> { + if (Objects.isNull(firearmId)) { + return null; + } + return cb.equal(root.get("firearm").get("id"), firearmId); + }; + } + + /** + * Checks if the jsonb tags contain the given JSON string. + */ + public static Specification containsTags(String tagsJson) { + return (root, query, cb) -> { + if (Objects.isNull(tagsJson)) { + return null; + } + // Invoke the PostgreSQL native function 'jsonb_contains' (backing the + // '@>' operator). The argument is cast via the 'jsonb' function-style + // cast so PostgreSQL resolves the correct overload instead of treating + // the bound String as varchar. + return cb.isTrue( + cb.function("jsonb_contains", Boolean.class, + root.get("tags"), + cb.function("jsonb", String.class, cb.literal(tagsJson))) + ); + }; + } +} From 812ab0ba913e4c02abff30b81d06b2d2d836905d Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 21 Aug 2026 09:31:34 +0800 Subject: [PATCH 115/117] docs(api): hide injected current user in OpenAPI Annotate @CurrentUser with @Parameter(hidden = true) so the server-injected user parameter no longer appears in the generated API docs. --- .../deltaforceguide/security/resolver/CurrentUser.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java index 5c66d3e..023e537 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java @@ -1,5 +1,7 @@ package com.onixbyte.deltaforceguide.security.resolver; +import io.swagger.v3.oas.annotations.Parameter; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -8,9 +10,13 @@ import java.lang.annotation.Target; /** * Annotation for injecting the currently authenticated {@code User} into controller method * parameters. The resolver extracts the user from the {@code SecurityContext}. + *

+ * Marked hidden so it is not rendered as a request parameter in the OpenAPI documentation, + * as the value is injected server-side rather than supplied by the client. * * @author zihluwang */ +@Parameter(hidden = true) @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface CurrentUser {} From ffcd234221a2d0e1f371afc7bec21659407529fe Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 21 Aug 2026 09:31:37 +0800 Subject: [PATCH 116/117] feat(auth): bind code to the requesting user Store the username and email alongside the code in the cached EmailVerificationCode object, and reject registration when the username or email no longer match the original request. --- .../domain/dto/EmailVerificationCode.java | 8 ++++++ .../deltaforceguide/service/AuthService.java | 26 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/EmailVerificationCode.java diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/EmailVerificationCode.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/EmailVerificationCode.java new file mode 100644 index 0000000..14a8f90 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/EmailVerificationCode.java @@ -0,0 +1,8 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record EmailVerificationCode( + String username, + String email, + String code +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index 4349d36..710e233 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -3,6 +3,7 @@ package com.onixbyte.deltaforceguide.service; import com.onixbyte.captcha.text.TextProducer; import com.onixbyte.deltaforceguide.client.EmailClient; import com.onixbyte.deltaforceguide.client.TokenClient; +import com.onixbyte.deltaforceguide.domain.dto.EmailVerificationCode; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; import com.onixbyte.deltaforceguide.domain.dto.SendVerificationCodeRequest; @@ -118,13 +119,24 @@ public class AuthService { var code = request.verificationCode(); var verificationCodeId = request.verificationCodeId(); - var storedCode = redisTemplate.opsForValue().get("verification-code:" + verificationCodeId); - if (!(storedCode instanceof String strStoredCode)) { - log.error("storedCode not a String, storedCode={}", storedCode); + var _req = redisTemplate.opsForValue().get("verification-code:" + verificationCodeId); + if (!(_req instanceof EmailVerificationCode verificationCode)) { + log.error("_req not a EmailVerificationCode, storedCode={}", _req); throw new InternalServerErrorException("系统内部错误,请重试"); } - if (!code.equalsIgnoreCase(strStoredCode)) { + if (!request.username().equals(verificationCode.username()) + || !request.email().equals(verificationCode.email())) { + log.error( + "Username and email address do not match the data submitted " + + "when requesting the verification code." + ); + log.error("EmailVerificationCode={}", verificationCode); + log.error("RegisterRequest={}", request); + throw new BadRequestException("用户名或邮箱与获取验证码时不一致,请重新获取验证码。"); + } + + if (!code.equalsIgnoreCase(verificationCode.code())) { throw new BadRequestException("邮箱验证码不正确,请重试。"); } @@ -189,7 +201,11 @@ public class AuthService { var code = textProducer.getText(); // Save verification code to cache. - redisTemplate.opsForValue().set("verification-code:" + uuid, code, Duration.ofMinutes(10L)); + redisTemplate.opsForValue().set( + "verification-code:" + uuid, + new EmailVerificationCode(request.username(), request.email(), code), + Duration.ofMinutes(10L) + ); // Send email. emailClient.sendVerificationCode(request.email(), request.username(), code, 10); From 099f138206485a98787b6e36d53a40c07c62f898 Mon Sep 17 00:00:00 2001 From: siujamo Date: Fri, 21 Aug 2026 10:47:05 +0800 Subject: [PATCH 117/117] chore(config): turn off thymeleaf template location check --- src/main/resources/application.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 12b0093..77a7916 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -32,6 +32,7 @@ spring: baseline-on-migrate: true thymeleaf: enabled: false + check-template-location: false mail: # This configuration is used to suppressing warnings of no beans found of JavaMailSender. host: example.smtp.com