47 Commits
Author SHA1 Message Date
siujamo 27bba95250 chore: remove stray server files from root directory on main branch 2026-08-28 10:01:39 +08:00
siujamo 9e86447c45 chore: sync main branch with develop (monorepo rename) 2026-08-28 09:52:50 +08:00
siujamo 8776f45684 chore: merge delta-force-guide-web into web/ (main)
git-subtree-dir: web
git-subtree-mainline: 694bebe2c8
git-subtree-split: b000336d22
2026-08-28 09:38:10 +08:00
siujamo 694bebe2c8 chore: merge delta-force-guide-server into server/ (main)
git-subtree-dir: server
git-subtree-mainline: bb08f1e4b6
git-subtree-split: bb08f1e4b6
2026-08-28 09:38:09 +08:00
zihluwang bb08f1e4b6 Merge pull request 'v1.4.0' (#2) from release/v1.4.0 into main
Reviewed-on: https://git.onixbyte.com/onixbyte/delta-force-guide-server/pulls/2
2026-06-16 01:18:50 +08:00
siujamo 2b099a6b2c 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.
2026-06-08 14:56:54 +08:00
siujamo 0269683b68 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/.
2026-06-08 14:46:19 +08:00
siujamo 3f2066f8b2 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.
2026-06-08 14:14:05 +08:00
siujamo a2469c7573 docs: update application configuration example 2026-06-08 13:32:16 +08:00
siujamo 62fcce3005 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.
2026-06-05 10:20:15 +08:00
siujamo c18a108a2b ci: fix build error 2026-06-05 09:40:12 +08:00
Siu Jam Oh 4803ae78c9 Merge pull request 'Develop' (#1) from develop into main
Reviewed-on: https://git.onixbyte.com/onixbyte/delta-force-guide-server/pulls/1
2026-06-04 17:40:34 +08:00
zihluwang b000336d22 Merge pull request #9 from zihluwang/develop
Enhance firearms management with filtering, display, and UI improvements
2026-04-09 17:27:22 +08:00
zihluwang cf2b57f745 feat: reinstall packages 2026-04-09 17:25:31 +08:00
zihluwang a1984c3ecb Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	package.json
#	pnpm-lock.yaml
2026-04-09 17:24:21 +08:00
zihluwang 3af4650d32 Merge pull request #8 from zihluwang/dependabot/npm_and_yarn/dependency-updates-1cafa9d752 2026-04-09 17:19:39 +08:00
dependabot[bot] a36c539ef1 chore: bump the dependency-updates group with 6 updates
Bumps the dependency-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.5` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.4` | `19.2.5` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.13.2` | `7.14.0` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.13.2` | `7.14.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.15` | `22.19.17` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.3` | `8.0.8` |


Updates `react` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

Updates `react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

Updates `react-router` from 7.13.2 to 7.14.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.14.0/packages/react-router)

Updates `react-router-dom` from 7.13.2 to 7.14.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom)

Updates `@types/node` from 22.19.15 to 22.19.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vite` from 8.0.3 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependency-updates
- dependency-name: "@types/node"
  dependency-version: 22.19.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 07:57:55 +00:00
zihluwang dad5c191fa feat: add tarball build script to package.json 2026-04-09 14:33:18 +08:00
zihluwang 78fa02e618 feat: add bullet calibre display to FirearmsPage 2026-04-09 14:30:21 +08:00
zihluwang 63f9885aa2 feat: add additional firearm attributes and DPS calculation to FirearmsPage 2026-04-09 14:17:38 +08:00
zihluwang 7f50fafee8 feat: add tag API and integrate tag selection in modifications page 2026-04-07 11:59:37 +08:00
zihluwang 3da706402d feat: enhance modification display with copy functionality and improved styling 2026-04-07 11:17:14 +08:00
zihluwang 9dfd52eb2d feat: integrate Ant Design components and configuration for improved UI 2026-04-07 11:06:34 +08:00
zihluwang 4294495ffa chore: add @ant-design/cssinjs 2026-04-07 11:06:21 +08:00
zihluwang a98902b08b fix: correct className syntax for consistent styling in firearms and mod codes pages 2026-04-07 01:28:34 +08:00
zihluwang bad31c6653 feat: implement modifications page with pagination and detail display 2026-04-06 21:02:16 +08:00
zihluwang ae912050b6 feat: add link to view modification codes for firearms 2026-04-06 20:53:13 +08:00
zihluwang feeb44bf6a feat: add firearm type filter to firearms page 2026-04-06 20:47:44 +08:00
zihluwang 452b807b62 feat: add firearm page for display firearms 2026-04-06 20:40:42 +08:00
zihluwang 38b25099de chore: add antd@6 for better UI and UX 2026-04-06 20:40:19 +08:00
zihluwang a0a5c835aa feat: add firearm management features
- Implemented API for fetching firearms and firearm details.
- Created a new page for displaying the list of firearms with search and filter options.
- Added Redux slice for managing firearms state.
- Integrated Redux Persist for state persistence.
- Updated routing to include firearms page.
- Removed obsolete modification codes data.
- Enhanced UI with responsive grid layout for firearms display.
- Added utility functions for handling URL query parameters.
2026-04-06 17:57:25 +08:00
zihluwang 864895d932 feat: add new weapon modifications for AWM, MCX LT, and KC17 2026-04-02 15:31:00 +08:00
zihluwang a4e5891189 feat: update README and add MIT Licence 2026-04-02 15:20:57 +08:00
zihluwang 335de44487 feat: add mode filtering to ModCodes component 2026-04-02 15:06:47 +08:00
zihluwang 3573a23acf Merge pull request #5 from zihluwang:dependabot/npm_and_yarn/globals-17.4.0
chore: bump globals from 16.5.0 to 17.4.0
2026-04-02 15:01:43 +08:00
zihluwang 6858e07603 Merge pull request #6 from zihluwang/dependabot/npm_and_yarn/typescript-6.0.2
chore: bump typescript from 5.9.3 to 6.0.2
2026-04-02 15:00:27 +08:00
dependabot[bot] aa2415a0cb chore: bump typescript from 5.9.3 to 6.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 06:54:37 +00:00
dependabot[bot] f9ff917360 chore: bump globals from 16.5.0 to 17.4.0
Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.4.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.4.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 06:54:36 +00:00
zihluwang 405c520cab Merge pull request #7 from zihluwang:dependabot/npm_and_yarn/dependency-updates-0c07818b49
chore: bump the dependency-updates group across 1 directory with 3 updates
2026-04-02 14:53:27 +08:00
dependabot[bot] 026843c6f0 chore: bump the dependency-updates group across 1 directory with 3 updates
Bumps the dependency-updates group with 3 updates in the / directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router), [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `react-router` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.13.2/packages/react-router)

Updates `react-router-dom` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.2/packages/react-router-dom)

Updates `vite` from 8.0.1 to 8.0.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: react-router-dom
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependency-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 02:44:23 +00:00
zihluwang 353e8a597b feat: add @tanstack/react-virtual for improved virtual scrolling and enhance modification codes with additional weapon data and filtering options 2026-04-02 10:42:36 +08:00
zihluwang 86b743dcc0 feat: remove GitHub Pages deployment workflow 2026-04-02 10:02:33 +08:00
zihluwang 00215decc5 feat: add modification codes page and update routing; remove about and contact pages 2026-04-02 10:01:18 +08:00
zihluwang c22bc08282 Merge pull request #1 from zihluwang/copilot/remove-auth-store-redux-axios
Remove auth, store, redux, and axios from template
2026-04-02 09:29:47 +08:00
copilot-swe-agent[bot]andzihluwang b3caeb8ce5 feat: remove auth, store, redux, and axios from template
Agent-Logs-Url: https://github.com/zihluwang/delta-force-firearm-modification-codes/sessions/3477dccf-d002-4dda-ad39-4768da36bb9e

Co-authored-by: zihluwang <47817169+zihluwang@users.noreply.github.com>
2026-04-02 01:27:56 +00:00
copilot-swe-agent[bot] 5d44e24c95 Initial plan 2026-04-02 01:24:00 +00:00
zihluwang 8b3ccc6a51 Initial commit 2026-04-02 09:23:57 +08:00
318 changed files with 16756 additions and 361 deletions

No files matched your search

+18 -5
View File
@@ -56,18 +56,31 @@ jobs:
- name: Generate image tags - name: Generate image tags
id: meta id: meta
run: | run: |
DOCKERHUB_USER="${{ secrets.DOCKER_HUB_USERNAME }}" DOCKERHUB_USER="${{ vars.DOCKER_HUB_USERNAME }}"
REPO_NAME=$(echo "${{ gitea.repository.name }}" | tr '[:upper:]' '[:lower:]') echo "tag_version=${DOCKERHUB_USER}/${APP_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "tag_version=${DOCKERHUB_USER}/${REPO_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT" echo "tag_latest=${DOCKERHUB_USER}/${APP_NAME}:latest" >> "$GITHUB_OUTPUT"
echo "tag_latest=${DOCKERHUB_USER}/${REPO_NAME}:latest" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: Dockerfile.ci 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 push: true
tags: | tags: |
${{ steps.meta.outputs.tag_version }} ${{ steps.meta.outputs.tag_version }}
${{ steps.meta.outputs.tag_latest }} ${{ steps.meta.outputs.tag_latest }}
- name: Push README to Docker Hub
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
@@ -0,0 +1,89 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: ahsarah-guide-server
jobs:
build-and-release:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
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
-Pvendor=${{ vars.VENDOR }}
- 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: ${{ vars.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate image tags
id: meta
run: |
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
with:
context: server
file: server/Dockerfile.ci
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
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: server/README.md
+115
View File
@@ -0,0 +1,115 @@
name: Build and Deploy
on:
release:
types: [published]
jobs:
build:
name: Build release archive
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build release archive
run: pnpm build:tar
- name: Clean up previous build artifacts on server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
command: rm -rf /tmp/dist.tar.gz /tmp/dist.tar.gz/
- name: Upload artifact to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "dist.tar.gz"
target: "/tmp/"
upload-release-asset:
name: Upload to Gitea Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact from server
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -e
SSH_DIR="${RUNNER_TEMP}/ssh"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$SSH_DIR/key"
chmod 600 "$SSH_DIR/key"
scp -i "$SSH_DIR/key" \
-P "$DEPLOY_PORT" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="$SSH_DIR/known_hosts" \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/dist.tar.gz" \
./dist.tar.gz
rm -rf "$SSH_DIR"
- name: Upload release asset via Gitea API
run: |
set -e
RELEASE_ID=$(jq -r '.release.id' "$GITEA_EVENT_PATH")
URL="${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=dist.tar.gz"
curl -fsSL \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/gzip" \
--data-binary "@dist.tar.gz" \
"${URL}"
deploy-to-server:
name: Deploy to onixbyte.cn
needs: [build, upload-release-asset]
runs-on: ubuntu-latest
steps:
- name: Extract archive and deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ vars.DEPLOY_HOST }}
username: ${{ vars.DEPLOY_USER }}
port: ${{ vars.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
set -e
DEPLOY_PATH="${{ vars.DEPLOY_PATH }}"
mkdir -p "$DEPLOY_PATH"
rm -rf "$DEPLOY_PATH"/*
tar -xzf /tmp/dist.tar.gz -C "$DEPLOY_PATH" --strip-components=1
chown -R caddy:caddy "$DEPLOY_PATH"
rm -f /tmp/dist.tar.gz
+97 -17
View File
@@ -97,7 +97,7 @@ fabric.properties
# Editor-based HTTP Client # Editor-based HTTP Client
http-client.private.env.json http-client.private.env.json
### Server ### Java
# Compiled class file # Compiled class file
*.class *.class
@@ -111,26 +111,18 @@ http-client.private.env.json
.mtj.tmp/ .mtj.tmp/
# Package Files # # Package Files #
*.jar
*.war *.war
*.nar *.nar
*.ear *.ear
*.zip *.zip
*.tar.gz *.tar.gz
*.rar *.rar
*.jar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid* hs_err_pid*
replay_pid* replay_pid*
# Config
config/*.yml
config/*.yaml
config/*.properties
# Tests
test/
### Gradle ### Gradle
.gradle .gradle
**/build/ **/build/
@@ -143,13 +135,6 @@ test/
gradle-app.setting gradle-app.setting
.gradletasknamecache .gradletasknamecache
### Claude Code
.claude/settings.local.json
.claude/memory/
.claude/plans/
.claude/worktrees/
.claude/scheduled_tasks.json
# Eclipse Gradle plugin generated files # Eclipse Gradle plugin generated files
# Eclipse Core # Eclipse Core
.project .project
@@ -158,5 +143,100 @@ gradle-app.setting
gradle.properties gradle.properties
### Node.js
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
### Server-specific
# Config
server/config/*.yml
server/config/*.yaml
server/config/*.properties
# Tests
server/test/
# frp config # frp config
frpc.toml frpc.toml
### Claude Code
.claude/settings.local.json
.claude/memory/
.claude/plans/
.claude/worktrees/
.claude/scheduled_tasks.json
-34
View File
@@ -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
+72 -28
View File
@@ -2,16 +2,25 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Coding Standards ## Monorepo Structure
- `server/` — Java 21 + Spring Boot 3.5 + Gradle (Kotlin DSL) REST API backend
- `web/` — React 19 + Vite 8 + TypeScript + Ant Design 6 + Tailwind CSS 4 frontend
## Server
### Coding Standards
- **Style**: Follow the Google Java Coding Style as the foundation. - **Style**: Follow the Google Java Coding Style as the foundation.
- **Indentation**: Use 4 spaces — no tabs. - **Indentation**: Use 4 spaces — no tabs.
- **Line length**: Maximum 100 characters per line. - **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"). - **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 ### Build & Test Commands
```bash ```bash
cd server
# Build the project (skip tests) # Build the project (skip tests)
./gradlew build -x test ./gradlew build -x test
@@ -19,10 +28,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
./gradlew test ./gradlew test
# Run a single test class # Run a single test class
./gradlew test --tests "com.onixbyte.deltaforceguide.service.PasswordEncoderTest" ./gradlew test --tests "com.onixbyte.ahsarahguide.service.PasswordEncoderTest"
# Run a specific test method
./gradlew test --tests "com.onixbyte.deltaforceguide.service.PasswordEncoderTest.generatePassword"
# Build the full JAR # Build the full JAR
./gradlew bootJar ./gradlew bootJar
@@ -30,14 +36,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
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. 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 ### Code Architecture
**Delta Force Guide Server** — A REST API backend for managing Delta Force game firearm builds/modifications. **Ahsarah Guide Server** — A REST API backend for managing firearm builds/modifications.
### Package structure ### Package structure
``` ```
com.onixbyte.deltaforceguide com.onixbyte.ahsarahguide
├── client/ # External service clients (TokenClient for JWT) ├── client/ # External service clients (TokenClient for JWT)
├── config/ # Spring beans: Security, CORS, Cache/Redis, Jackson, MyBatis, Spring Data ├── config/ # Spring beans: Security, CORS, Cache/Redis, Jackson, MyBatis, Spring Data
├── controller/ # REST controllers (Firearm, Modification, Tag, Auth) ├── controller/ # REST controllers (Firearm, Modification, Tag, Auth)
@@ -65,16 +71,9 @@ com.onixbyte.deltaforceguide
- **JPA + native queries**: Most CRUD uses Spring Data JPA. Native queries (in `ModificationRepository`) handle JSONB tag filtering with Postgres `@>` operator. - **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. - **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`. - **JSONB storage**: `Modification.tags` and `Modification.accessories` (including nested `Tuning` objects) are stored as JSONB columns using Hypersistence Utils `JsonType`.
- **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. - **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.
- **DTOs as Java records**: All request/response objects are immutable records with static `from()` factory methods for entity→DTO conversion. - **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). - **Flyway migrations**: SQL migrations in `src/main/resources/db/migration/`.
### 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 ### API endpoints
@@ -94,16 +93,61 @@ com.onixbyte.deltaforceguide
Conventional commits: `feat:`, `chore:`, `fix:`. Messages are in English, present tense imperative style. Conventional commits: `feat:`, `chore:`, `fix:`. Messages are in English, present tense imperative style.
### External dependencies ---
- **DB**: PostgreSQL (via Flyway migrations), H2 in test ## Web
- **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 ### Commands
- `dev`: Enables Swagger UI, connects to dev DB/Redis at `dfguide.onixbyte.cn`. Config in `config/application-dev.yaml`. ```bash
- Default profile: Used for production, no Swagger, connects to production datasource. cd web
pnpm install # Install dependencies (pnpm required)
pnpm dev # Start Vite dev server
pnpm build # TypeScript check + Vite production build
pnpm build:tar # Build + tar.gz archive (used by CI)
pnpm preview # Preview production build locally
pnpm lint # ESLint
```
No test suite exists in this project.
### Architecture
Chinese-language SPA for browsing and managing Ahsarah Guide (阿萨拉向导). The frontend talks to the Spring Boot backend via REST APIs.
**App shell** (`src/main.tsx`): React 19 + React Router 7 + Redux Toolkit + Ant Design 6 + Tailwind CSS 4. Wraps the router in Redux `Provider` → `PersistGate` (Redux Persist) → Ant Design `StyleProvider`/`ConfigProvider` (locale `zh_CN`).
**Routing** (`src/router/index.tsx`): Two layout groups:
- `HeroLayout` (nav header + footer) for `/`, `/firearms`, `/mod-codes`
- `EmptyLayout` (minimal) for `/login`
All page components are lazy-loaded via `createBrowserRouter` + `lazy()`.
**State** (`src/store/`): Redux Toolkit with two slices — `auth` (current user) and `firearms` (paginated firearm list). State is persisted to `localStorage` or `sessionStorage` based on the `VITE_REDUX_STORAGE` env var. Use typed hooks from `src/hooks/store.ts` (`useAppDispatch`, `useAppSelector`).
**API layer** (`src/api/`): Axios instance (`src/shared/web-client/`) with base URL from `VITE_API_BASE_URL`, 10s timeout, and credentials. API modules: `FirearmApi`, `ModificationApi`, `TagApi`, `AuthApi`.
**Pages**:
- `FirearmsPage` — paginated card grid with type filter, create/edit modals (admin-only), delete with popconfirm
- `ModCodesPage` — paginated list with tag multi-select and firearmId query param filter, create/edit modals with nested accessory/tuning form lists
- `LoginPage` — simple username/password form, dispatches `setCurrentUser` on success
**Shared form components**: `FirearmForm` and `ModificationForm` are reused by both create and edit modals. `ModificationForm` fetches all firearms for its weapon selector and supports a `lockFirearmId` prop that disables the selector (used when navigating from a specific firearm).
**Type system** (`src/types/`): `Firearm` with weapon stats, `Modification` with nested `Accessory[]` → `Tuning[]`, `Page<T>` for paginated API responses, `User` for auth.
**Vite config**: Alias `@` → `./src`. Plugins: React, Tailwind CSS 4, port checker. Build uses rolldown with manual chunk splitting for React, Redux, Ant Design, React Router, and rc-* packages.
**Styling**: Tailwind CSS 4 with CSS layers (`theme, base, antd, components, utilities`). Responsive grid for mod code cards (1→2→3→4 columns). Prettier: 100 print width, no semicolons, double quotes, trailing commas ES5.
### Environment variables
```
VITE_API_BASE_URL=/api # Backend API base URL
VITE_REDUX_STORAGE=local # "local" or "session" for Redux persistence
```
### Contributing conventions
- User-facing copy, documentation, and code comments in British English
- Commit messages use `chore:` prefix for dependency updates (per Dependabot config)
-7
View File
@@ -1,7 +0,0 @@
FROM amazoncorretto:21-alpine
WORKDIR /app
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
+20 -132
View File
@@ -1,150 +1,38 @@
# Delta Force Guide Server # Ahsarah Guide (阿萨拉向导)
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. Ahsarah Guide is a full-stack web application for browsing and managing firearm modification guides.
## Monorepo Structure
## Tech Stack | Directory | Stack | Description |
|-----------|-------|-------------|
| Layer | Technology | | `server/` | Java 21 + Spring Boot 3.5 + Gradle | REST API backend |
|-----------|------------------------------------------------------------| | `web/` | React 19 + Vite 8 + TypeScript + Ant Design 6 | Web frontend |
| 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 ## Quick Start
### Prerequisites ### Server
- JDK 21 (Amazon Corretto recommended) Prerequisites: JDK 21 (Amazon Corretto), PostgreSQL, Redis
- PostgreSQL
- Redis
### Configure
Copy and customise the development config:
```bash ```bash
cp config/application-prod.yaml.example \ cd server
config/application-dev.yaml cp config/application-prod.yaml.example config/application-dev.yaml
``` # Edit config/application-dev.yaml with your database and Redis credentials
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 ./gradlew bootJar
java -jar build/libs/ahsarah-guide-server-*.jar
``` ```
### Run ### Web
Prerequisites: Node.js 20+, pnpm
```bash ```bash
SPRING_PROFILES_ACTIVE=prod java -jar build/libs/delta-force-guide-server-$version.jar cd web
pnpm install
pnpm dev
``` ```
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 `:<version>`
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 ## Licence
This project is licensed under the [MIT Licence](LICENCE). Copyright &copy; 2026 OnixByte. MIT. See [LICENCE](LICENCE) for details.
-78
View File
@@ -1,78 +0,0 @@
plugins {
java
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
val artefactVersion: String by project
val buildChannel: String by project
val vendor: String by project
group = "com.onixbyte.helix"
version = artefactVersion
tasks.withType<JavaCompile> {
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.onixbyte.cryptoToolbox)
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.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)
}
tasks.processResources {
filesMatching("application.yaml") {
expand(
"appVersion" to artefactVersion,
"channel" to buildChannel,
"vendor" to vendor
)
}
}
tasks.test {
useJUnitPlatform()
}
-55
View File
@@ -1,55 +0,0 @@
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).
File renamed without changes.
+3
View File
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
@@ -0,0 +1,86 @@
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
-Pvendor=${{ vars.VENDOR }}
- 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: ${{ vars.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate image tags
id: meta
run: |
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
with:
context: .
file: Dockerfile.ci
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
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
+162
View File
@@ -0,0 +1,162 @@
### macOS
# General
.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# 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
# Gradle and Maven with auto-import
*.iml
*.ipr
# File-based project format
*.iws
# IntelliJ
out/
# JIRA plugin
atlassian-ide-plugin.xml
# 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
### Claude Code
.claude/settings.local.json
.claude/memory/
.claude/plans/
.claude/worktrees/
.claude/scheduled_tasks.json
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
gradle.properties
# frp config
frpc.toml
+109
View File
@@ -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`.
- **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).
### 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.
+1 -1
View File
@@ -6,5 +6,5 @@ RUN chmod +x ./gradlew && \
FROM amazoncorretto:21-alpine AS runner FROM amazoncorretto:21-alpine AS runner
WORKDIR /app WORKDIR /app
COPY --from=builder /app/build/libs/delta-force-guide-server-1.0.0.jar app.jar COPY --from=builder /app/build/libs/ahsarah-guide-server-*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]
+19
View File
@@ -0,0 +1,19 @@
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="ahsarah-guide-server" \
org.opencontainers.image.description="REST API backend for Ahsarah Guide" \
org.opencontainers.image.version="${IMAGE_VERSION}" \
org.opencontainers.image.vendor="${IMAGE_VENDOR}" \
org.opencontainers.image.source="https://onixbyte.dev/onixbyte/ahsarah-guide" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.created="${BUILD_DATE}"
ENTRYPOINT ["java", "-jar", "app.jar"]
+21
View File
@@ -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.
+150
View File
@@ -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 `:<version>`
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 &copy; 2026 OnixByte.
+64
View File
@@ -0,0 +1,64 @@
plugins {
java
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
val artefactVersion: String by project
val buildChannel: String by project
val vendor: String by project
group = "com.onixbyte.ahsarahguide"
version = artefactVersion
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
// BOMs are imported as platforms, so they cannot be bundled.
implementation(platform(libs.aws.sdk.bom))
implementation(platform(libs.onixbyte.versionCatalogue))
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)
runtimeOnly(libs.postgres.driver)
testImplementation(libs.bundles.testing)
testRuntimeOnly(libs.bundles.testRuntime)
}
tasks.processResources {
filesMatching("application.yaml") {
println("appVersion = ${artefactVersion}, channel = ${buildChannel}, vendor = ${vendor}")
expand(
"appVersion" to artefactVersion,
"channel" to buildChannel,
"vendor" to vendor
)
}
}
tasks.test {
useJUnitPlatform()
}
+131
View File
@@ -0,0 +1,131 @@
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
# Redis server password.
#
# Uncomment if the server requires a password.
#
# password: qwerty
logging:
pattern:
# 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
# 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. Add your custom origins here.
allowed-origins: []
# 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 to allow requests originating from private network addresses.
allow-private-network: false
# Headers allowed in cross-origin requests.
allowed-headers:
- "Content-Type"
- "Authorization"
# HTTP methods allowed in cross-origin requests.
#
# 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
- POST
- PUT
- PATCH
- DELETE
# Response headers exposed to the browser in cross-origin responses.
exposed-headers:
- "Content-Type"
- "Authorization"
# How long the CORS pre-flight response may be cached by the browser.
#
# Default to PT2H (2 hours).
max-age: PT2H
jwt:
# Issuer identifier, domain name is recommended.
issuer: ahsarah-guide.local
# Token signing 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:
# GitHub webhook secret. Used to verify the signature of incoming webhook requests.
secret: 123456
# GitHub usernames allowed to trigger webhook events.
allowed-users: [ octocat, onixbyte ]
springdoc:
api-docs:
# Whether to expose the OpenAPI documentation. Set to `false` if you do not need it
# (recommended in production mode).
enabled: true
swagger-ui:
# Whether to expose the Swagger UI. Set to `false` if you do not need it
# (recommended in production mode).
enabled: true
@@ -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" } spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" }
jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" } 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 Doc
spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" } 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" } 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" } 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] [plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" } spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" }
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
@@ -11,4 +11,4 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "delta-force-guide-server" rootProject.name = "ahsarah-guide-server"
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Entry point for the Ahsarah Guide Server application.
*
* @author zihluwang
*/
@EnableAsync
@SpringBootApplication
public class AhsarahGuideApplication {
public static void main(String[] args) {
SpringApplication.run(AhsarahGuideApplication.class, args);
}
}
@@ -0,0 +1,109 @@
package com.onixbyte.ahsarahguide.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;
@Autowired
public EmailClient(
JavaMailSender mailSender,
TemplateEngine emailTemplateEngine
) {
this.mailSender = mailSender;
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.
*
* @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<String, Object> 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.<String, Object>of(
"username", username,
"code", code,
"expireMinutes", expireMinutes
);
sendTemplateMail(toRecipient, subject, "verification-code", variables);
}
}
@@ -0,0 +1,75 @@
package com.onixbyte.ahsarahguide.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.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.properties.TokenProperties;
import com.onixbyte.ahsarahguide.utils.DateTimeUtil;
import org.springframework.beans.factory.annotation.Autowired;
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 {
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);
}
}
@@ -0,0 +1,48 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.interceptor.GitHubWebhookInterceptor;
import com.onixbyte.ahsarahguide.interceptor.TrafficInterceptor;
import com.onixbyte.ahsarahguide.properties.AppProperties;
import com.onixbyte.ahsarahguide.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 {
private final TrafficInterceptor trafficInterceptor;
private final GitHubWebhookInterceptor gitHubWebhookInterceptor;
private final CurrentUserArgumentResolver currentUserArgumentResolver;
@Autowired
public AppConfig(
TrafficInterceptor trafficInterceptor,
GitHubWebhookInterceptor gitHubWebhookInterceptor,
CurrentUserArgumentResolver currentUserArgumentResolver
) {
this.trafficInterceptor = trafficInterceptor;
this.gitHubWebhookInterceptor = gitHubWebhookInterceptor;
this.currentUserArgumentResolver = currentUserArgumentResolver;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(trafficInterceptor);
registry.addInterceptor(gitHubWebhookInterceptor)
.addPathPatterns("/webhooks/github");
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
}
@@ -0,0 +1,102 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.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.
* <p>
* 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.
* <p>
* The configuration includes:
* <ul>
* <li>Custom {@link RedisCacheManager} with JSON serialisation support</li>
* <li>Configured {@link RedisTemplate} for direct Redis operations</li>
* </ul>
*
* @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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory
) {
var redisTemplate = new RedisTemplate<String, Object>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(JacksonRedisSerialiser.INSTANCE);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
@@ -0,0 +1,49 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.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.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
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
) {
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());
corsConfiguration.setMaxAge(properties.maxAge());
corsConfiguration.setAllowPrivateNetwork(properties.allowPrivateNetwork());
corsConfiguration.setExposedHeaders(List.of(properties.exposedHeaders()));
var corsConfigurationSource = new UrlBasedCorsConfigurationSource();
corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
return corsConfigurationSource;
}
}
@@ -0,0 +1,37 @@
package com.onixbyte.ahsarahguide.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();
}
}
@@ -0,0 +1,21 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.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<WebhookFilter> webhookFilterBean(WebhookFilter webhookFilter) {
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
registrationBean.setFilter(webhookFilter);
registrationBean.addUrlPatterns("/webhooks/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
@@ -0,0 +1,25 @@
package com.onixbyte.ahsarahguide.config;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.onixbyte.ahsarahguide.shared.JacksonModules;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
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 {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomiser() {
return (builder) -> {
builder.modules(JacksonModules.DATE_TIME_MODULE);
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
};
}
}
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.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.ahsarahguide.mapper"})
public class MyBatisConfig {
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.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 = "Ahsarah Guide Server",
description = "API for Ahsarah Guide",
version = "1.4.0",
contact = @Contact(
name = "Zihlu Wang",
email = "zihlu.wang@onixbyte.com"
),
license = @License(
name = "MIT",
url = "https://onixbyte.dev/onixbyte/ahsarah-guide/raw/branch/main/LICENCE"
)
)
)
@Configuration
public class OpenApiConfiguration {
}
@@ -0,0 +1,113 @@
package com.onixbyte.ahsarahguide.config;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.onixbyte.ahsarahguide.exeption.ForbiddenException;
import com.onixbyte.ahsarahguide.exeption.UnauthorisedException;
import com.onixbyte.ahsarahguide.filter.TokenAuthenticationFilter;
import com.onixbyte.ahsarahguide.properties.CookieProperties;
import com.onixbyte.ahsarahguide.properties.TokenProperties;
import com.onixbyte.ahsarahguide.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;
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;
/**
* Spring Security configuration defining authentication, authorisation, and filter chains.
*
* @author zihluwang
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@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.
*
* @param httpSecurity the HTTP security builder
* @return the configured security filter chain
*/
@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
.anyRequest().permitAll()
)
.exceptionHandling(customiser -> customiser
.authenticationEntryPoint((request, response, authException) -> {
log.error("Unauthenticated request: {}", request, authException);
throw new UnauthorisedException("请先登录");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
log.error("Denied request: {}", request, accessDeniedException);
throw new ForbiddenException("权限不足");
})
)
.addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class)
.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
) {
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();
}
}
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.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.ahsarahguide.repository"})
public class SpringDataConfig {
}
@@ -0,0 +1,10 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.properties.GitHubWebhookProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({GitHubWebhookProperties.class})
public class WebhookConfig {
}
@@ -0,0 +1,59 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ManageRoleRequest;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.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.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;
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 = "为用户分配角色(仅超级管理员可操作)")
@PreAuthorize("hasRole('SUPER_USER')")
@PutMapping("/users/{id}/roles")
public ResponseEntity<Void> assignRole(
@PathVariable @Positive(message = "用户ID必须为正数") Long id,
@Valid @RequestBody ManageRoleRequest request
) {
userService.assignRole(id, request.role());
return ResponseEntity.noContent().build();
}
@Operation(description = "移除用户角色(仅超级管理员可操作)")
@PreAuthorize("hasRole('SUPER_USER')")
@DeleteMapping("/users/{id}/roles/{role}")
public ResponseEntity<Void> removeRole(
@PathVariable @Positive(message = "用户ID必须为正数") Long id,
@PathVariable String role
) {
userService.removeRole(id, role);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,62 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.*;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.service.AuthService;
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;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for user authentication endpoints (login, logout).
*
* @author zihluwang
*/
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@Operation(description = "用户登录")
@PostMapping("/login")
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
var user = authService.login(request);
return authService.getUserResponseResponseEntity(user);
}
@RequiresAuth
@Operation(description = "退出登录")
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
var expiredCookie = authService.logout();
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
.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<UserResponse> register(@Validated @RequestBody RegisterRequest request) {
var user = authService.register(request);
return authService.getUserResponseResponseEntity(user);
}
}
@@ -0,0 +1,32 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.ahsarahguide.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;
/**
* REST controller for retrieving daily-generated passwords.
*
* @author zihluwang
*/
@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();
}
}
@@ -0,0 +1,72 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.FirearmRequest;
import com.onixbyte.ahsarahguide.domain.dto.FirearmResponse;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.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;
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")
public class FirearmController {
private final FirearmService firearmService;
public FirearmController(FirearmService firearmService) {
this.firearmService = firearmService;
}
@Operation(description = "获取分页武器数据")
@Validated
@GetMapping
public PageResponse<FirearmResponse> 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,
@RequestParam(required = false) FirearmType type
) {
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);
}
@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) {
firearmService.deleteFirearm(id);
}
}
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ErrorResponse;
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;
import org.springframework.web.server.ResponseStatusException;
/**
* Global exception handler that translates exceptions into standard error responses.
*
* @author zihluwang
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(ResponseStatusException exception) {
var status = exception.getStatusCode();
return ResponseEntity.status(status)
.body(new ErrorResponse(exception.getReason()));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("请先登录"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> 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));
}
}
@@ -0,0 +1,109 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ModificationBatchCreateRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationResponse;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUser;
import com.onixbyte.ahsarahguide.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;
import java.util.List;
/**
* REST controller for modification CRUD operations, including batch creation and deletion.
*
* @author zihluwang
*/
@Tag(name = "改装管理", description = "对枪械改装的管理")
@RestController
@RequestMapping("/modifications")
public class ModificationController {
private final ModificationService modificationService;
public ModificationController(ModificationService modificationService) {
this.modificationService = modificationService;
}
@Operation(description = "分页查询改装信息")
@Validated
@GetMapping
public PageResponse<ModificationResponse> 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,
@RequestParam(required = false) List<String> tags
) {
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);
}
@RequiresAuth
@Operation(description = "创建改装")
@PostMapping
public ModificationResponse create(
@CurrentUser User user,
@Valid @RequestBody ModificationRequest request
) {
return modificationService.create(request, user);
}
@RequiresAuth
@Operation(description = "批量创建改装")
@PostMapping("/batch")
public List<ModificationResponse> batchCreate(
@CurrentUser User user,
@Valid @RequestBody ModificationBatchCreateRequest request
) {
return modificationService.batchCreate(request.modifications(), user);
}
@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
public void batchDelete(@RequestParam List<@Positive Long> ids) {
modificationService.batchDelete(ids);
}
}
@@ -0,0 +1,34 @@
package com.onixbyte.ahsarahguide.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;
import org.springframework.web.bind.annotation.RestController;
import com.onixbyte.ahsarahguide.service.ModificationService;
import java.util.List;
/**
* REST controller for retrieving available modification tags.
*
* @author zihluwang
*/
@Tag(name = "标签管理", description = "管理标签信息")
@RestController
@RequestMapping("/tags")
public class TagController {
private final ModificationService modificationService;
public TagController(ModificationService modificationService) {
this.modificationService = modificationService;
}
@Operation(description = "查询指定武器或所有武器的标签")
@GetMapping
public List<String> getTags(@RequestParam(required = false) Long firearmId) {
return modificationService.findAllTags(firearmId);
}
}
@@ -0,0 +1,79 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.BuildSummaryResponse;
import com.onixbyte.ahsarahguide.domain.dto.ChangePasswordRequest;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.domain.dto.UpdateProfileRequest;
import com.onixbyte.ahsarahguide.domain.dto.UserProfileResponse;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUser;
import com.onixbyte.ahsarahguide.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<Void> changePassword(
@CurrentUser User user,
@Valid @RequestBody ChangePasswordRequest request
) {
userService.changePassword(user, request);
return ResponseEntity.noContent().build();
}
@Operation(description = "分页查询当前用户的改枪码列表")
@GetMapping("/me/builds")
public PageResponse<BuildSummaryResponse> 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));
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.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 {
private final AppService appService;
public VersionController(AppService appService) {
this.appService = appService;
}
@Operation(description = "获取当前应用版本号")
@GetMapping
public String getVersion() {
return appService.getVersion();
}
}
@@ -0,0 +1,47 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.ahsarahguide.service.WebhookService;
import com.onixbyte.ahsarahguide.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;
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;
@Tag(name = "WebHook")
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private static final Logger log = LoggerFactory.getLogger(WebhookController.class);
private final WebhookService webhookService;
public WebhookController(WebhookService webhookService) {
this.webhookService = webhookService;
}
@Operation(description = "GitHub WebHook 接口")
@PostMapping("/github")
public ResponseEntity<Void> 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();
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.domain.converter;
import com.onixbyte.ahsarahguide.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<FirearmType, Integer> {
@Override
public Integer convertToDatabaseColumn(FirearmType attribute) {
return attribute == null ? null : attribute.getCode();
}
@Override
public FirearmType convertToEntityAttribute(Integer dbData) {
return FirearmType.fromCode(dbData);
}
}
@@ -0,0 +1,28 @@
package com.onixbyte.ahsarahguide.domain.converter;
import com.onixbyte.ahsarahguide.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.
*
* @author zihluwang
*/
@Converter
public class ModificationStatusConverter implements AttributeConverter<ModificationStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(ModificationStatus attribute) {
return Optional.ofNullable(attribute)
.map(ModificationStatus::getCode)
.orElse(null);
}
@Override
public ModificationStatus convertToEntityAttribute(Integer dbData) {
return ModificationStatus.fromCode(dbData);
}
}
@@ -0,0 +1,25 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
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,
@NotBlank(message = "配件名称不能为空")
String accessoryName,
List<@Valid TuningRequest> tunings
) {
public List<TuningRequest> tunings() {
return tunings == null ? new ArrayList<>() : tunings;
}
}
@@ -0,0 +1,27 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.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,
List<TuningResponse> 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()
);
}
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.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<String> tags
) {
// from() factory to be implemented when CustomModification entity lands
}
@@ -0,0 +1,23 @@
package com.onixbyte.ahsarahguide.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
) {}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* DTO representing a single daily-generated password for a map.
*
* @author zihluwang
*/
public record DailyPassword(
String mapName,
String password
) {
}
@@ -0,0 +1,19 @@
package com.onixbyte.ahsarahguide.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,
List<DailyPassword> passwords,
String source,
LocalDateTime lastUpdated,
Long timestamp
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* DTO holding metadata about the daily password source and update tracking.
*
* @author zihluwang
*/
public record DailyPasswordMetadata(
String version,
String author
) {
}
@@ -0,0 +1,14 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* Response DTO wrapping daily password data with metadata.
*
* @author zihluwang
*/
public record DailyPasswordResponse(
String status,
String message,
DailyPasswordData data,
DailyPasswordMetadata metadata
) {
}
@@ -0,0 +1,8 @@
package com.onixbyte.ahsarahguide.domain.dto;
public record EmailVerificationCode(
String username,
String email,
String code
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* Standard error response body returned on API failures.
*
* @author zihluwang
*/
public record ErrorResponse(
String message
) {
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
/**
* Request DTO for creating or updating a firearm.
*
* @author zihluwang
*/
public record FirearmRequest(
String name,
FirearmType type,
String level,
String calibre,
Integer fireRate,
Integer armourDamage,
Integer bodyDamage,
String review
) {
}
@@ -0,0 +1,36 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.Firearm;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
/**
* Response DTO for a firearm record, including associated modifications.
*
* @author zihluwang
*/
public record FirearmResponse(
Long id,
String name,
FirearmType type,
String level,
String calibre,
Integer fireRate,
Integer armourDamage,
Integer bodyDamage,
String review
) {
public static FirearmResponse from(Firearm firearm) {
return new FirearmResponse(
firearm.getId(),
firearm.getName(),
firearm.getType(),
firearm.getLevel(),
firearm.getCalibre(),
firearm.getFireRate(),
firearm.getArmourDamage(),
firearm.getBodyDamage(),
firearm.getReview()
);
}
}
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.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,
GitHubWebhookSender sender
) {
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.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<GitHubWebhookLabel> labels
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.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
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.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
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.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
) {
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.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 = "登录名称不能为空")
@Schema(description = "用户名或电子邮箱", requiredMode = Schema.RequiredMode.REQUIRED)
String principle,
@NotBlank(message = "登录口令不能为空")
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
String credential
) {
}
@@ -0,0 +1,13 @@
package com.onixbyte.ahsarahguide.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
) {}
@@ -0,0 +1,18 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
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
) {
}
@@ -0,0 +1,18 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotEmpty;
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
) {
}
@@ -0,0 +1,38 @@
package com.onixbyte.ahsarahguide.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;
/**
* Request DTO for creating or updating a modification.
*
* @author zihluwang
*/
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<String> tags() {
return tags == null ? new ArrayList<>() : tags;
}
public List<AccessoryRequest> accessories() {
return accessories == null ? new ArrayList<>() : accessories;
}
}
@@ -0,0 +1,39 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.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,
String name,
String code,
List<String> tags,
List<AccessoryResponse> accessories,
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.getAccessories() == null
? List.of()
: modification.getAccessories().stream().map(AccessoryResponse::from).toList(),
modification.getNote(),
modification.getAuthor(),
modification.getVideoUrl()
);
}
}
@@ -0,0 +1,29 @@
package com.onixbyte.ahsarahguide.domain.dto;
import org.springframework.data.domain.Page;
import java.util.List;
/**
* Generic paginated response wrapper for list endpoints.
*
* @author zihluwang
*/
public record PageResponse<T>(
List<T> items,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageResponse<T> from(Page<T> source) {
return new PageResponse<>(
source.getContent(),
source.getNumber(),
source.getSize(),
source.getTotalElements(),
source.getTotalPages()
);
}
}
@@ -0,0 +1,31 @@
package com.onixbyte.ahsarahguide.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,
@NotBlank(message = "验证码 ID 不能为空") String verificationCodeId,
@NotBlank(message = "验证码不能为空") String verificationCode
) {
}
@@ -0,0 +1,9 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
public record SendVerificationCodeRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "电子邮箱地址不能为空") String email
) {
}
@@ -0,0 +1,6 @@
package com.onixbyte.ahsarahguide.domain.dto;
public record SendVerificationCodeResponse(
String verificationCodeId
) {
}
@@ -0,0 +1,18 @@
package com.onixbyte.ahsarahguide.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,
@NotNull(message = "调校值不能为空")
Double tuningValue
) {
}
@@ -0,0 +1,21 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.Tuning;
/**
* Response DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningResponse(
String tuningName,
Double tuningValue
) {
public static TuningResponse from(Tuning tuning) {
return new TuningResponse(
tuning.getTuningName(),
tuning.getTuningValue()
);
}
}
@@ -0,0 +1,14 @@
package com.onixbyte.ahsarahguide.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
) {}
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.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 enabled,
LocalDateTime createdAt,
List<String> 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<String> roles) {
return new UserProfileResponse(
user.getId(),
user.getUsername(),
user.getNickname() == null || user.getNickname().isBlank()
? user.getUsername() : user.getNickname(),
user.getEmail(),
user.getAvatarUrl(),
user.getEnabled(),
user.getCreatedAt(),
roles
);
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.User;
import java.time.LocalDateTime;
/**
* Response DTO for a user account, including associated credentials.
*
* @author zihluwang
*/
public record UserResponse(
Long id,
String username,
String email,
LocalDateTime expiration
) {
public static UserResponse from(User user, LocalDateTime expiration) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
expiration
);
}
}
@@ -0,0 +1,72 @@
package com.onixbyte.ahsarahguide.domain.entity;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Entity representing an accessory attached to a modification, stored as JSONB.
*
* @author zihluwang
*/
public class Accessory {
private String slotName;
private String accessoryName;
private List<Tuning> 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<Tuning> getTunings() {
return tunings;
}
public void setTunings(List<Tuning> tunings) {
this.tunings = tunings;
}
public void addTuning(Tuning tuning) {
this.tunings.add(tuning);
}
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);
}
}
@@ -0,0 +1,232 @@
package com.onixbyte.ahsarahguide.domain.entity;
import com.onixbyte.ahsarahguide.domain.converter.FirearmTypeConverter;
import com.onixbyte.ahsarahguide.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;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
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 {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "type", nullable = false)
@Convert(converter = FirearmTypeConverter.class)
private FirearmType type;
@Column(name = "level", nullable = false)
private String level;
@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<Modification> 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 FirearmType getType() {
return type;
}
public void setType(FirearmType type) {
this.type = type;
}
public String getLevel() {
return level;
}
public void setLevel(String level) {
this.level = level;
}
public String getReview() {
return review;
}
public void setReview(String review) {
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<Modification> getModifications() {
return modifications;
}
public void setModifications(List<Modification> 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);
}
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<Modification> 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<Modification> 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;
}
}
}
@@ -0,0 +1,289 @@
package com.onixbyte.ahsarahguide.domain.entity;
import com.onixbyte.ahsarahguide.domain.converter.ModificationStatusConverter;
import com.onixbyte.ahsarahguide.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.
*
* @author zihluwang
*/
@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
@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;
@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 = "jsonb", nullable = false)
private List<String> tags = new ArrayList<>();
@Type(JsonType.class)
@Column(name = "accessories", columnDefinition = "jsonb", nullable = false)
private List<Accessory> accessories = 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;
@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;
}
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<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public List<Accessory> getAccessories() {
return accessories;
}
public void setAccessories(List<Accessory> 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;
}
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;
}
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();
}
public static class Builder {
private Long id;
private User user;
private Firearm firearm;
private String name;
private String code;
private List<String> tags;
private List<Accessory> accessories;
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;
}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder code(String code) {
this.code = code;
return this;
}
public Builder tags(List<String> tags) {
this.tags = tags;
return this;
}
public Builder accessories(List<Accessory> accessories) {
this.accessories = accessories;
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 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 = 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;
}
}
}
@@ -0,0 +1,50 @@
package com.onixbyte.ahsarahguide.domain.entity;
import java.util.Objects;
/**
* Entity representing a tuning adjustment for an accessory, stored as JSONB within Accessory.
*
* @author zihluwang
*/
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;
}
@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);
}
}
@@ -0,0 +1,217 @@
package com.onixbyte.ahsarahguide.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.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
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 {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false)
private String username;
@Column(name = "email", nullable = false)
private String email;
@Column(name = "nickname")
private String nickname;
@Column(name = "avatar_url")
private String avatarUrl;
@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<UserCredential> 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 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 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<UserCredential> getCredentials() {
return credentials;
}
public void setCredentials(List<UserCredential> 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);
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long id;
private String username;
private String email;
private String nickname;
private String avatarUrl;
private Boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<UserCredential> 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 nickname(String nickname) {
this.nickname = nickname;
return this;
}
public Builder avatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
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<UserCredential> 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.nickname = this.nickname == null || this.nickname.isBlank()
? this.username : this.nickname;
user.avatarUrl = this.avatarUrl;
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;
}
}
}
@@ -0,0 +1,143 @@
package com.onixbyte.ahsarahguide.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 representing a user credential linked to an authentication provider.
*
* @author zihluwang
*/
@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)
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;
}
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;
}
}
}
@@ -0,0 +1,81 @@
package com.onixbyte.ahsarahguide.domain.entity;
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 {
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);
}
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;
}
}
}
@@ -0,0 +1,101 @@
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.Column;
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;
import jakarta.persistence.Table;
/**
* Entity representing a role assignment linking a user to a security role.
* <p>
* A user owns at most one role assignment, so the user ID is the primary key.
*
* @author zihluwang
*/
@Entity
@Table(name = "user_role")
public class UserRole {
@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;
@Column(name = "role", nullable = false, length = 32)
private String role;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
if (user != null) {
this.userId = user.getId();
}
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private User user;
private Long userId;
private String role;
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.user = this.user;
userRole.role = this.role;
if (this.user != null) {
userRole.userId = this.user.getId();
}
if (this.userId != null) {
userRole.userId = this.userId;
}
return userRole;
}
}
}
@@ -0,0 +1,50 @@
package com.onixbyte.ahsarahguide.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),
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;
}
/**
* 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;
}
for (FirearmType type : values()) {
if (type.code == code) {
return type;
}
}
throw new IllegalArgumentException("Unknown FirearmType code: " + code);
}
}
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.enumeration;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Enumeration of modification statuses.
* <p>
* 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<Integer, ModificationStatus> 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);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.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);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.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);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.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);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.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);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.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);
}
}
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff. Show more