46 Commits
Author SHA1 Message Date
siujamo e7da3a76b7 ci: recover artefact uploading 2026-05-25 14:45:26 +08:00
siujamo 5cea825bc0 chore: remove gitlab artifacts to avoid slow uploads
Remove artifacts uploading from the build stage. Since we use a shared
docker socket on the same runner host, the package stage can access the
locally built jar file directly without needing gitlab coordinator upload/download.
2026-05-25 14:39:33 +08:00
siujamo bd2748e25c fix: disable provenance in docker build to fix GitLab Registry 0B display
Add `--provenance=false` flag to `docker build` command. This stops Docker BuildKit
from generating OCI Referrers/attestations, which are not correctly parsed by GitLab
Container Registry and cause the UI to display 0B size and "missing manifest digest" errors.
2026-05-25 13:58:14 +08:00
siujamo 0f1093774f fix: use GitLab predefined environment variables for container registry
Replace custom registry variables with GitLab's predefined CI_REGISTRY,
CI_REGISTRY_IMAGE, and CI_REGISTRY_USER to ensure the built-in CI_JOB_TOKEN
has correct push permissions.
2026-05-25 11:47:22 +08:00
siujamo d19b7f5563 fix: allow jar files to be copied in Docker build context
Add '!build/libs/*.jar' to .dockerignore so that Docker build can access the build
artifacts in the package stage.
2026-05-25 11:05:25 +08:00
Siu Jamo ea1456c5a5 Merge branch 'develop' into 'main'
chore: switch CI to Docker socket binding and add artefact version parameter

See merge request onixbyte/delta-force-guide-server!2
2026-05-25 10:20:50 +08:00
siujamo b60cd36535 chore: switch CI to Docker socket binding and add artefact version parameter
Replace DinD services with unix:///var/run/docker.sock socket binding to
fix "Cannot connect to Docker daemon" errors. Add -PartefactVersion
parameter to Gradle build for release version tracking.
2026-05-25 10:17:33 +08:00
Siu Jamo 1115cd4527 Merge branch 'develop' into 'main'
v1.3.0: Introduced Daily Password

See merge request onixbyte/delta-force-guide-server!1
2026-05-25 09:11:13 +08:00
siujamo 491be4f4dd chore: simplify GitLab CI to release-only workflow with tag-triggered pipeline
Replace the full CI pipeline (build → image → push → SSH deploy on every branch)
with a focused release workflow: build JAR on tag push, package Docker image
tagged with the release tag, and push to registry.onixbyte.cn.
2026-05-25 09:05:13 +08:00
siujamo b94a09691d chore: add GitHub Actions workflow for release-based build and deploy 2026-05-19 10:38:11 +08:00
siujamo 24b7913908 chore: add GitLab CI pipeline with build, container registry push, and deploy stages 2026-05-18 17:07:34 +08:00
siujamo 20d2edc9b1 feat: use @RequiresAuth annotation instead of manual path listing in security config 2026-05-15 11:41:14 +08:00
siujamo 6d869d5145 docs: improve API endpoints table formatting in CLAUDE.md 2026-05-15 11:35:26 +08:00
siujamo 130d360556 feat: add daily password endpoint with Redis caching 2026-05-15 11:32:31 +08:00
siujamo 0ae23fa0cb chore: add Claude Code local files to .gitignore 2026-05-15 10:41:46 +08:00
zihluwangandClaude Opus 4.7 559ae34966 chore: remove legacy Groovy Gradle files and relocated converter
build.gradle and settings.gradle superseded by build.gradle.kts
and settings.gradle.kts. FirearmTypeConverter moved to
domain/converter/ package.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:58:25 +08:00
zihluwangandClaude Opus 4.7 70ae945cd2 chore: add CLAUDE.md with coding standards and build commands
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:56:48 +08:00
zihluwang 7fda77370e chore: update artefactVersion to 1.2.0 2026-04-23 16:24:41 +08:00
zihluwang 384e17e79c feat: add AccessoryResponse and TuningResponse DTOs for accessory and tuning data representation 2026-04-23 16:10:05 +08:00
zihluwang 353c05339e feat: refactor batch delete endpoint to use request parameters and update SQL schema for firearm table 2026-04-22 16:35:39 +08:00
zihluwang 5ce8a994a4 feat: add modification creation and deletion endpoints, including batch operations and request DTOs 2026-04-21 23:39:05 +08:00
zihluwang 93dbd857e0 feat: add update and delete operations for Firearm, including error handling 2026-04-21 14:20:45 +08:00
zihluwang dec7f3c7d2 feat: add Accessory and Tuning classes, update Modification to include accessories 2026-04-21 14:07:17 +08:00
zihluwang 17048104d9 feat: add logout operation description and update schema annotations in LoginRequest 2026-04-17 10:57:41 +08:00
zihluwang f0a8006097 feat: add Swagger annotations for user authentication endpoints and update validation in LoginRequest 2026-04-17 10:55:39 +08:00
zihluwang a58fefbd2d feat: add addFirearm endpoint and FirearmRequest DTO for firearm creation 2026-04-16 09:52:55 +08:00
zihluwang cb50892ffe feat: add builder pattern for Firearm, Modification, User, UserCredential, and UserCredentialId classes 2026-04-15 11:14:19 +08:00
zihluwang 1fc7b932bc feat: add logout endpoint and refactor cookie management in AuthController 2026-04-14 12:13:02 +08:00
zihluwang 8fbb73740c feat: implement user authentication with login endpoint and cookie management 2026-04-13 17:25:34 +08:00
zihluwang 75abbb0a2a feat: add Swagger annotations for Firearm, Modification, and Tag controllers 2026-04-13 14:38:50 +08:00
zihluwang 5e9b29c186 feat: implement JWT authentication with TokenClient, TokenAuthenticationFilter, and SecurityConfig 2026-04-13 14:32:34 +08:00
zihluwang 0a6813ceea chore: add Spring Security to library 2026-04-12 05:37:24 +08:00
zihluwang e65df08d1b feat: implement User and UserCredential models with repository and service layers 2026-04-12 05:32:31 +08:00
zihluwang 6e6843c412 Merge pull request #1 from zihluwang/develop
Enhance Firearm model and add tag filtering to queries
2026-04-09 17:53:28 +08:00
zihluwang bd1f2441f3 feat: add calibre, fire rate, armour damage, and body damage fields to Firearm model and update related response and migration scripts 2026-04-09 13:28:28 +08:00
zihluwang 0992635391 feat: add nullability annotations to findById method in ModificationRepository 2026-04-09 11:37:02 +08:00
zihluwang a28033ff4c feat: add tag filtering to modification queries and implement tag retrieval endpoint 2026-04-07 11:58:46 +08:00
zihluwang 1a88cf37bc chore: update artefact version to 1.1.0 2026-04-06 21:03:50 +08:00
zihluwang 3f6d40fba7 feat: migrate database from MySQL to PostgreSQL and update schema 2026-04-06 21:02:56 +08:00
zihluwang 33e29f48b9 feat: add firearm type filtering to page queries in Firearm and Modification services 2026-04-06 14:01:13 +08:00
zihluwang 80dc5170a4 refactor: rename query controllers and services for consistency 2026-04-03 17:47:40 +08:00
zihluwang 20c2da10ab feat: enhance Firearm entity and add query services with controllers 2026-04-03 15:38:24 +08:00
zihluwang 2616e70062 feat: add configurations and utility classes 2026-04-03 14:53:59 +08:00
zihluwang 8053bbb6b6 feat: add Firearm and Modification entities with database schema 2026-04-03 14:29:04 +08:00
zihluwang 7c249ee4be ci: add Dockerfile and .dockerignore for containerisation 2026-04-03 10:51:01 +08:00
zihluwang 06210f22dc feat: initialize project structure with Gradle and Spring Boot setup 2026-04-03 10:45:20 +08:00
145 changed files with 4208 additions and 4882 deletions

No files matched your search

+6 -2
View File
@@ -1,2 +1,6 @@
node_modules/
README.md
delta-force-guide-server.iml
build/
!.gitlab-ci.yml
!build/libs/*.jar
.idea/
.gradle
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
-27
View File
@@ -1,27 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore: "
groups:
dependency-updates:
patterns: ["*"]
update-types: ["minor", "patch"]
ignore:
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+127
View File
@@ -0,0 +1,127 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: delta-force-guide-server
IMAGE_REGISTRY: ${{ vars.GITLAB_REGISTRY }}
IMAGE_NAME: ${{ vars.GITLAB_IMAGE_NAME }}
jobs:
# ================================================================
# Job 1 — Package: build the JAR with Gradle
# ================================================================
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21 (Corretto)
uses: actions/setup-java@v4
with:
java-version: 21
distribution: corretto
cache: gradle
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build with Gradle
run: ./gradlew build
- name: Upload JAR artifact
uses: actions/upload-artifact@v4
with:
name: app-jar
path: build/libs/delta-force-guide-server-*.jar
retention-days: 1
# ================================================================
# Job 2 — Build & push Docker image to GitHub Container Registry
# ================================================================
build-and-push:
needs: package
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Download JAR artifact
uses: actions/download-artifact@v4
with:
name: app-jar
path: build/libs
- name: Resolve JAR file path
id: jar
run: echo "file=$(ls build/libs/delta-force-guide-server-*.jar | head -1)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitLab Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ vars.GITLAB_REGISTRY_USER }}
password: ${{ secrets.GITLAB_REGISTRY_PASSWORD }}
- name: Generate image tags
id: meta
run: |
echo "version=${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "latest=${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.ci
build-args: JAR_FILE=${{ steps.jar.outputs.file }}
push: true
tags: |
${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ================================================================
# Job 3 — Deploy on the target server via SSH
# ================================================================
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
echo '=== Pulling image ==='
echo '${{ secrets.GITLAB_REGISTRY_PASSWORD }}' | docker login ${{ env.IMAGE_REGISTRY }} \
-u ${{ vars.GITLAB_REGISTRY_USER }} --password-stdin
docker pull ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
echo '=== Stopping old container ==='
docker stop ${{ env.APP_NAME }} || true
docker rm ${{ env.APP_NAME }} || true
echo '=== Starting new container ==='
docker run -d \
--name ${{ env.APP_NAME }} \
--restart unless-stopped \
-p ${DEPLOY_PORT:-8080}:8080 \
${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
echo '=== Cleaning up old images ==='
docker image prune -f
echo '=== Deployment complete ==='
+29 -160
View File
@@ -97,30 +97,7 @@ fabric.properties
# Editor-based HTTP Client
http-client.private.env.json
### Gradle
.gradle
**/build/
!**/src/**/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Avoid ignore Gradle wrappper properties
!gradle-wrapper.properties
# Cache of project
.gradletasknamecache
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
### Java
### Server
# Compiled class file
*.class
@@ -134,155 +111,47 @@ gradle-app.setting
.mtj.tmp/
# Package Files #
*.jar
*.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*
### Node.js
# Logs
logs
#*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Config
config/*.yml
config/*.yaml
config/*.properties
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Tests
test/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
### Gradle
.gradle
**/build/
!**/src/**/build/
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
!gradle/
!gradle/wrapper
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
gradle-app.setting
.gradletasknamecache
# Coverage directory used by tools like istanbul
coverage
*.lcov
### Claude Code
.claude/settings.local.json
.claude/memory/
.claude/plans/
.claude/worktrees/
.claude/scheduled_tasks.json
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# 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
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
#.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
+57
View File
@@ -0,0 +1,57 @@
variables:
GRADLE_OPTS: -Dorg.gradle.daemon=false
DOCKER_HOST: unix:///var/run/docker.sock
stages:
- build
- package
- deploy
build:
stage: build
image: amazoncorretto:21-alpine
cache:
key: gradle
paths:
- .gradle/wrapper
- .gradle/caches
before_script:
- chmod +x gradlew
script:
- ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG"
artifacts:
paths:
- build/libs/*.jar
rules:
- if: $CI_COMMIT_TAG
package:
stage: package
image: docker:27
needs:
- build
script:
- JAR_FILE=$(find build/libs -name '*.jar' | head -1)
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
- docker build
--provenance=false
-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"
rules:
- if: $CI_COMMIT_TAG
deploy:
stage: deploy
image: docker:27
needs:
- package
script:
- echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
-24
View File
@@ -1,24 +0,0 @@
printWidth: 100
tabWidth: 2
useTabs: false
semi: false
singleQuote: false
quoteProps: as-needed
jsxSingleQuote: false
trailingComma: es5
bracketSpacing: true
bracketSameLine: true
arrowParens: always
#rangeStart: 0
#rangeEnd: n
#parser:
#filepath:
#requirePragma: false
#insertPragma: false
#proseWrap: preserve
htmlWhitespaceSensitivity: css
#vueIndentScriptAndStyle: false
endOfLine: lf
#embeddedLanguageFormatting: auto
# Enforce single attribute per line in HTML, Vue and JSX
#singleAttributePerLine: false
-22
View File
@@ -1,22 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"request": "launch",
"name": "pnpm dev",
"command": "pnpm dev",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "build",
"command": "pnpm build",
"cwd": "${workspaceFolder}"
}
]
}
+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.
+8 -7
View File
@@ -1,9 +1,10 @@
FROM node:20-alpine as builder
FROM amazoncorretto:21 AS builder
WORKDIR /app
COPY . .
RUN corepack enable pnpm
RUN pnpm install
RUN pnpm build
ADD . .
RUN chmod +x ./gradlew && \
./gradlew build -x test
FROM nginx:1.27 as runner
COPY --from=builder /app/dist/ /usr/share/nginx/html/
FROM amazoncorretto:21-alpine AS runner
WORKDIR /app
COPY --from=builder /app/build/libs/delta-force-guide-server-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
+7
View File
@@ -0,0 +1,7 @@
FROM amazoncorretto:21-alpine
WORKDIR /app
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
-21
View File
@@ -1,21 +0,0 @@
MIT Licence
Copyright (c) 2026 Zihlu Wang
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.
-96
View File
@@ -1,96 +0,0 @@
# Delta Force Firearm Modification Codes
Delta Force Firearm Modification Codes is a lightweight web app for browsing and filtering firearm modification codes for Delta Force.
The site is built with Vite, React, TypeScript, Tailwind CSS, and React Router. It presents a searchable code library with filtering by weapon, mode, and tag, and includes quick copy support for each modification code.
## Features
- Browse a curated list of firearm modification codes.
- Filter results by weapon, mode, and tag.
- Copy modification codes directly from the interface.
- Render large lists efficiently with window virtualisation.
- Deploy as a static site.
## Tech Stack
- Vite
- React 19
- TypeScript
- Tailwind CSS 4
- React Router 7
- @tanstack/react-virtual
- Day.js
## Getting Started
### Prerequisites
- Node.js 20 or later is recommended.
- pnpm is required for dependency management and scripts.
### Install dependencies
```bash
pnpm install
```
### Start the development server
```bash
pnpm dev
```
### Build for production
```bash
pnpm build
```
### Preview the production build locally
```bash
pnpm preview
```
## Available Scripts
- `pnpm dev`: start the Vite development server.
- `pnpm build`: run TypeScript compilation and create a production build.
- `pnpm preview`: preview the production bundle locally.
- `pnpm lint`: run project linting.
- `pnpm deploy`: build and publish the site with `gh-pages`.
## Project Structure
```text
src/
components/ Shared UI components
data/ Modification code dataset
init/ Application initialisation
layout/ Route layouts
page/ Route pages
router/ Router configuration
```
The current dataset is stored in `src/data/modification-codes.json`.
## Deployment
The repository is configured for static deployment. The `public/CNAME` file indicates the site is intended to be served on `onixbyte.dev`.
To deploy:
```bash
pnpm deploy
```
## Contributing
Contributions are welcome. If you want to improve the dataset, refine the filtering experience, or fix UI issues, open an issue or submit a pull request.
When contributing, please keep documentation and user-facing copy in British English.
## Licence
This project is released under the MIT Licence. See `LICENCE` for details.
+65
View File
@@ -0,0 +1,65 @@
plugins {
java
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
val artefactVersion: String by project
group = "com.onixbyte.helix"
version = artefactVersion
tasks.withType<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.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.test {
useJUnitPlatform()
}
+1
View File
@@ -0,0 +1 @@
artefactVersion = 1.2.0
+80
View File
@@ -0,0 +1,80 @@
[versions]
jspecifyVersion = "1.0.0"
javaJwtVersion = "4.5.1"
postgresDriverVersion = "42.7.9"
h2Version = "2.2.224"
springBootVersion = "3.5.13"
springSecurityVersion = "6.5.2"
reactorVersion = "3.7.8"
junitPlatformVersion = "1.12.2"
onixbyteVersion = "3.3.0"
onixbyteCaptcha = "1.1.0"
onixbyteRegions = "2025.12.0"
awsSdkVersion = "2.25.48"
commonsIoVersion = "2.16.1"
commonsCollections = "4.5.0"
commonsLangVersion = "3.20.0"
mybatisVersion = "3.0.5"
jacksonVersion = "2.19.2"
hypersistenceVersion = "3.14.0"
springDependencyManagementVersion = "1.1.7"
springDocVersion = "2.8.16"
[libraries]
# General Utilities
jspecify-core = { group = "org.jspecify", name = "jspecify", version.ref = "jspecifyVersion" }
commons-io = { group = "commons-io", name = "commons-io", version.ref = "commonsIoVersion" }
commons-collections = { group = "org.apache.commons", name = "commons-collections4", version.ref = "commonsCollections" }
commons-lang = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commonsLangVersion" }
jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jacksonVersion" }
# Onixbyte Ecosystem
onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" }
onixbyte-tuple = { group = "com.onixbyte", name = "tuple" }
onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" }
onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" }
onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" }
onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" }
onixbyte-regions = { group = "com.onixbyte", name = "regions4j", version.ref = "onixbyteRegions" }
# Persistence & Database
mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter", version.ref = "mybatisVersion" }
spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }
hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" }
postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" }
mysql-driver = { group = "com.mysql", name = "mysql-connector-j" }
h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" }
spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" }
flyway-core = { group = "org.flywaydb", name = "flyway-core" }
flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" }
flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" }
# Spring Boot Core & Web
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
spring-boot-starter-webFlux = { group = "org.springframework.boot", name = "spring-boot-starter-webflux" }
spring-boot-starter-validation = { group = "org.springframework.boot", name = "spring-boot-starter-validation" }
spring-boot-starter-cache = { group = "org.springframework.boot", name = "spring-boot-starter-cache" }
spring-boot-configurationProcessor = { group = "org.springframework.boot", name = "spring-boot-configuration-processor" }
spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator" }
# Security & Auth
spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" }
jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" }
# Spring Doc
spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" }
# Cloud Services
aws-sdk-bom = { group = "software.amazon.awssdk", name = "bom", version.ref = "awsSdkVersion" }
aws-sdk-s3 = { group = "software.amazon.awssdk", name = "s3" }
# Testing
spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "springBootVersion" }
spring-security-test = { group = "org.springframework.security", name = "spring-security-test" }
reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref = "reactorVersion" }
junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" }
mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" }
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/onixbyte.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>三角洲行动改枪码库</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-49
View File
@@ -1,49 +0,0 @@
{
"name": "delta-force-guide-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:tar": "pnpm build && tar -czf dist.tar.gz dist",
"lint": "eslint .",
"preview": "vite preview",
"deploy": "pnpm build && gh-pages -d dist",
"predeploy": "pnpm build"
},
"dependencies": {
"@ant-design/cssinjs": "^2.1.2",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-virtual": "^3.13.24",
"antd": "^6.3.6",
"axios": "^1.15.2",
"dayjs": "^1.11.20",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-redux": "^9.2.0",
"react-router": "^7.14.2",
"react-router-dom": "^7.14.2",
"redux-persist": "^6.0.0",
"tailwindcss": "^4.2.4"
},
"devDependencies": {
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"globals": "^17.5.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"vite": "^8.0.10"
},
"pnpm": {
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"@tailwindcss/oxide"
]
}
}
-2361
View File
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
-1
View File
@@ -1 +0,0 @@
onixbyte.dev
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/></defs><rect x="-37.5" width="450" fill="#ffffff" y="-37.499999" height="449.999989" fill-opacity="1"/><rect x="-37.5" width="450" fill="#324370" y="-37.499999" height="449.999989" fill-opacity="1"/><g fill="#ffffff" fill-opacity="1"><g transform="translate(27.506677, 211.874986)"><g><path d="M 12.359375 -0.453125 C 8.003906 -1.816406 5.003906 -4.3125 3.359375 -7.9375 C 1.710938 -11.5625 1.550781 -16.253906 2.875 -22.015625 C 4.195312 -27.773438 6.519531 -32.46875 9.84375 -36.09375 C 13.175781 -39.71875 17.34375 -42.210938 22.34375 -43.578125 Z M 27.578125 -34.109375 L 30.015625 -44.796875 L 31.875 -44.796875 C 39.46875 -44.796875 44.882812 -42.851562 48.125 -38.96875 C 51.363281 -35.09375 52.128906 -29.441406 50.421875 -22.015625 C 48.722656 -14.585938 45.351562 -8.929688 40.3125 -5.046875 C 35.28125 -1.171875 28.96875 0.765625 21.375 0.765625 C 20.519531 0.765625 19.898438 0.742188 19.515625 0.703125 L 21.953125 -9.921875 C 22.722656 -9.835938 23.34375 -9.796875 23.8125 -9.796875 C 26.84375 -9.796875 29.367188 -10.710938 31.390625 -12.546875 C 33.421875 -14.378906 34.796875 -16.851562 35.515625 -19.96875 L 36.421875 -24.0625 C 37.140625 -27.175781 36.910156 -29.648438 35.734375 -31.484375 C 34.566406 -33.316406 32.46875 -34.234375 29.4375 -34.234375 C 28.96875 -34.234375 28.347656 -34.191406 27.578125 -34.109375 Z M 27.578125 -34.109375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(80.815405, 211.874986)"><g><path d="M 31.171875 -34.5625 C 28.816406 -34.5625 26.363281 -34.003906 23.8125 -32.890625 L 21.953125 -24.890625 C 22.460938 -25.066406 23.0625 -25.15625 23.75 -25.15625 C 25.15625 -25.15625 26.125 -24.71875 26.65625 -23.84375 C 27.1875 -22.96875 27.28125 -21.804688 26.9375 -20.359375 L 22.265625 0 L 35 0 L 40.0625 -21.890625 C 41 -25.984375 40.726562 -29.117188 39.25 -31.296875 C 37.78125 -33.472656 35.085938 -34.5625 31.171875 -34.5625 Z M 17.28125 -19.96875 L 17.796875 -28.671875 L 18.171875 -33.796875 L 7.75 -33.796875 L -0.0625 0 L 12.671875 0 L 17.21875 -19.84375 Z M 17.28125 -19.96875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(123.500788, 211.874986)"><g><path d="M 19.515625 -33.796875 L 11.703125 0 L -1.03125 0 L 6.78125 -33.796875 Z M 22.46875 -46.40625 L 20.484375 -37.765625 L 7.75 -37.765625 L 9.71875 -46.40625 Z M 22.46875 -46.40625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(144.811483, 211.874986)"><g><path d="M 45.5625 -33.796875 L 29.953125 -17.59375 L 38.53125 0 L 23.671875 0 L 19.703125 -10.625 L 19.453125 -10.625 L 10.5625 0 L -3.578125 0 L 13.5625 -17.921875 L 5.5 -33.796875 L 20.484375 -33.796875 L 23.8125 -24.828125 L 24.0625 -24.828125 L 31.484375 -33.796875 Z M 45.5625 -33.796875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(187.496867, 211.874986)"><g><path d="M 24 -44.03125 L 13.890625 0 L -0.25 0 L 9.859375 -44.03125 Z M 29.25 -34.375 L 31.421875 -44.03125 L 39.171875 -44.03125 C 42.578125 -44.03125 45.21875 -43.003906 47.09375 -40.953125 C 48.976562 -38.910156 49.554688 -36.332031 48.828125 -33.21875 C 47.585938 -27.800781 44.347656 -24.382812 39.109375 -22.96875 L 39.109375 -22.71875 C 44.390625 -21.4375 46.328125 -17.769531 44.921875 -11.71875 C 44.109375 -8.257812 42.21875 -5.441406 39.25 -3.265625 C 36.289062 -1.085938 33.019531 0 29.4375 0 L 21.3125 0 L 23.671875 -10.234375 L 27 -10.234375 C 27.601562 -10.234375 28.179688 -10.382812 28.734375 -10.6875 C 29.285156 -10.988281 29.765625 -11.40625 30.171875 -11.9375 C 30.578125 -12.46875 30.863281 -13.054688 31.03125 -13.703125 L 31.171875 -14.34375 C 31.421875 -15.320312 31.300781 -16.140625 30.8125 -16.796875 C 30.320312 -17.460938 29.628906 -17.796875 28.734375 -17.796875 L 25.40625 -17.796875 L 27.515625 -26.875 L 29.828125 -26.875 C 30.722656 -26.875 31.5625 -27.203125 32.34375 -27.859375 C 33.132812 -28.523438 33.640625 -29.351562 33.859375 -30.34375 L 33.984375 -30.96875 C 34.191406 -31.914062 34.0625 -32.71875 33.59375 -33.375 C 33.125 -34.039062 32.441406 -34.375 31.546875 -34.375 Z M 29.25 -34.375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(237.285815, 211.874986)"><g><path d="M 44.609375 -33.796875 L 25.015625 -1.03125 C 24.847656 -0.769531 24.703125 -0.507812 24.578125 -0.25 L 23.671875 -16.890625 L 32.0625 -33.796875 Z M 21.25 4.609375 C 19.03125 7.460938 16.585938 9.644531 13.921875 11.15625 C 11.253906 12.675781 8.148438 13.4375 4.609375 13.4375 C 1.835938 13.4375 -0.53125 13.050781 -2.5 12.28125 L -0.765625 4.671875 L 4.34375 4.671875 C 7.550781 4.671875 9.816406 3.113281 11.140625 0 L 5.5 -33.796875 L 19.203125 -33.796875 Z M 21.25 4.609375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(276.387414, 211.874986)"><g><pathLine truncated

Before

Width:  |  Height:  |  Size: 6.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+14
View File
@@ -0,0 +1,14 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
rootProject.name = "delta-force-guide-server"
-13
View File
@@ -1,13 +0,0 @@
import { LoginRequest, User } from "@/types"
import { WebClient } from "@/shared/web-client"
export async function login(loginRequest: LoginRequest): Promise<User> {
const { data } = await WebClient.post<User>("/auth/login", {
...loginRequest,
})
return data
}
export async function logout() {
await WebClient.get<void>("/auth/logout")
}
-56
View File
@@ -1,56 +0,0 @@
import { AddFirearmRequest, Direction, Firearm, FirearmType, Page, PageQueryParams } from "@/types"
import { WebClient } from "@/shared/web-client"
import { asUrlSearchParam } from "@/utils/query-param-utils.ts"
interface FirearmParams extends PageQueryParams {
type?: FirearmType
}
/**
* 查询武器列表
*
* @param params 分页查询参数¬
*/
export async function getFirearms(params?: FirearmParams): Promise<Page<Firearm>> {
let uri = "/firearms"
const urlSearchParam = asUrlSearchParam(params)
if (params?.type) {
urlSearchParam.append("type", params.type)
}
if (urlSearchParam.size > 0) {
uri = uri.concat("?", urlSearchParam.toString())
}
const { data } = await WebClient.get<Page<Firearm>>(uri)
return data
}
/**
* 根据 ID 查询武器
*
* @param id 武器 ID
*/
export async function getFirearm(id: number): Promise<Firearm> {
const { data } = await WebClient.get<Firearm>(`/firearms/${id}`)
return data
}
/**
* 新建武器
* @param request
*/
export async function addFirearm(request: AddFirearmRequest): Promise<Firearm> {
const { data } = await WebClient.post<Firearm>("/firearms", request)
return data
}
export async function editFirearm(id: number, request: AddFirearmRequest): Promise<Firearm> {
const { data } = await WebClient.put<Firearm>(`/firearms/${id}`, request)
return data
}
export async function removeFirearm(id: number) {
await WebClient.delete<void>(`/firearms/${id}`)
}
-4
View File
@@ -1,4 +0,0 @@
export * as FirearmApi from "./firearm-api"
export * as ModificationApi from "./modification-api"
export * as TagApi from "./tag-api"
export * as AuthApi from "./auth-api"
-67
View File
@@ -1,67 +0,0 @@
import { Modification, ModificationRequest, Page, PageQueryParams } from "@/types"
import { WebClient } from "@/shared/web-client"
import { asUrlSearchParam } from "@/utils/query-param-utils.ts"
interface ModificationParams extends PageQueryParams {
firearmId?: string
tags?: string[]
}
export async function getModifications(params?: ModificationParams): Promise<Page<Modification>> {
let uri = "/modifications"
const urlSearchParams = asUrlSearchParam(params)
if (params?.firearmId) {
urlSearchParams.append("firearmId", "" + params.firearmId)
}
if (params?.tags) {
params.tags.forEach((tag) => urlSearchParams.append("tags", tag))
}
if (urlSearchParams.size > 0) {
uri = uri.concat("?", urlSearchParams.toString())
}
const { data } = await WebClient.get<Page<Modification>>(uri)
return data
}
export async function getModification(id: number): Promise<Modification> {
const { data } = await WebClient.get<Modification>(`/modifications/${id}`)
return data
}
export async function addModification(modification: ModificationRequest): Promise<Modification> {
const { data } = await WebClient.post<Modification>("/modifications", modification)
return data
}
export async function addModifications(
modifications: ModificationRequest[]
): Promise<Modification[]> {
const { data } = await WebClient.post<Modification[]>("/modifications/batch", {
modifications,
})
return data
}
export async function editModification(
id: number,
modification: ModificationRequest
): Promise<Modification> {
const { data } = await WebClient.put<Modification>(`/modifications/${id}`, modification)
return data
}
export async function removeModification(
id: number
): Promise<void> {
await WebClient.delete(`/modifications/${id}`)
}
export async function removeModifications(ids: number[]) {
const urlSearchParams = new URLSearchParams()
ids.forEach((id) => urlSearchParams.append("ids", "" + id))
await WebClient.delete(`/modifications/batch-delete?${urlSearchParams.toString()}`)
}
-18
View File
@@ -1,18 +0,0 @@
import { WebClient } from "@/shared/web-client"
export async function getTags(firearmId?: number): Promise<string[]> {
let uri = "/tags"
const urlSearchParam = new URLSearchParams()
if (firearmId) {
urlSearchParam.append("firearmId", "" + firearmId)
}
if (urlSearchParam.size > 0) {
uri = uri.concat("?", urlSearchParam.toString())
}
const { data } = await WebClient.get<string[]>(uri)
return data
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/></defs><rect x="-37.5" width="450" fill="#ffffff" y="-37.499999" height="449.999989" fill-opacity="1"/><rect x="-37.5" width="450" fill="#324370" y="-37.499999" height="449.999989" fill-opacity="1"/><g fill="#ffffff" fill-opacity="1"><g transform="translate(27.506677, 211.874986)"><g><path d="M 12.359375 -0.453125 C 8.003906 -1.816406 5.003906 -4.3125 3.359375 -7.9375 C 1.710938 -11.5625 1.550781 -16.253906 2.875 -22.015625 C 4.195312 -27.773438 6.519531 -32.46875 9.84375 -36.09375 C 13.175781 -39.71875 17.34375 -42.210938 22.34375 -43.578125 Z M 27.578125 -34.109375 L 30.015625 -44.796875 L 31.875 -44.796875 C 39.46875 -44.796875 44.882812 -42.851562 48.125 -38.96875 C 51.363281 -35.09375 52.128906 -29.441406 50.421875 -22.015625 C 48.722656 -14.585938 45.351562 -8.929688 40.3125 -5.046875 C 35.28125 -1.171875 28.96875 0.765625 21.375 0.765625 C 20.519531 0.765625 19.898438 0.742188 19.515625 0.703125 L 21.953125 -9.921875 C 22.722656 -9.835938 23.34375 -9.796875 23.8125 -9.796875 C 26.84375 -9.796875 29.367188 -10.710938 31.390625 -12.546875 C 33.421875 -14.378906 34.796875 -16.851562 35.515625 -19.96875 L 36.421875 -24.0625 C 37.140625 -27.175781 36.910156 -29.648438 35.734375 -31.484375 C 34.566406 -33.316406 32.46875 -34.234375 29.4375 -34.234375 C 28.96875 -34.234375 28.347656 -34.191406 27.578125 -34.109375 Z M 27.578125 -34.109375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(80.815405, 211.874986)"><g><path d="M 31.171875 -34.5625 C 28.816406 -34.5625 26.363281 -34.003906 23.8125 -32.890625 L 21.953125 -24.890625 C 22.460938 -25.066406 23.0625 -25.15625 23.75 -25.15625 C 25.15625 -25.15625 26.125 -24.71875 26.65625 -23.84375 C 27.1875 -22.96875 27.28125 -21.804688 26.9375 -20.359375 L 22.265625 0 L 35 0 L 40.0625 -21.890625 C 41 -25.984375 40.726562 -29.117188 39.25 -31.296875 C 37.78125 -33.472656 35.085938 -34.5625 31.171875 -34.5625 Z M 17.28125 -19.96875 L 17.796875 -28.671875 L 18.171875 -33.796875 L 7.75 -33.796875 L -0.0625 0 L 12.671875 0 L 17.21875 -19.84375 Z M 17.28125 -19.96875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(123.500788, 211.874986)"><g><path d="M 19.515625 -33.796875 L 11.703125 0 L -1.03125 0 L 6.78125 -33.796875 Z M 22.46875 -46.40625 L 20.484375 -37.765625 L 7.75 -37.765625 L 9.71875 -46.40625 Z M 22.46875 -46.40625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(144.811483, 211.874986)"><g><path d="M 45.5625 -33.796875 L 29.953125 -17.59375 L 38.53125 0 L 23.671875 0 L 19.703125 -10.625 L 19.453125 -10.625 L 10.5625 0 L -3.578125 0 L 13.5625 -17.921875 L 5.5 -33.796875 L 20.484375 -33.796875 L 23.8125 -24.828125 L 24.0625 -24.828125 L 31.484375 -33.796875 Z M 45.5625 -33.796875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(187.496867, 211.874986)"><g><path d="M 24 -44.03125 L 13.890625 0 L -0.25 0 L 9.859375 -44.03125 Z M 29.25 -34.375 L 31.421875 -44.03125 L 39.171875 -44.03125 C 42.578125 -44.03125 45.21875 -43.003906 47.09375 -40.953125 C 48.976562 -38.910156 49.554688 -36.332031 48.828125 -33.21875 C 47.585938 -27.800781 44.347656 -24.382812 39.109375 -22.96875 L 39.109375 -22.71875 C 44.390625 -21.4375 46.328125 -17.769531 44.921875 -11.71875 C 44.109375 -8.257812 42.21875 -5.441406 39.25 -3.265625 C 36.289062 -1.085938 33.019531 0 29.4375 0 L 21.3125 0 L 23.671875 -10.234375 L 27 -10.234375 C 27.601562 -10.234375 28.179688 -10.382812 28.734375 -10.6875 C 29.285156 -10.988281 29.765625 -11.40625 30.171875 -11.9375 C 30.578125 -12.46875 30.863281 -13.054688 31.03125 -13.703125 L 31.171875 -14.34375 C 31.421875 -15.320312 31.300781 -16.140625 30.8125 -16.796875 C 30.320312 -17.460938 29.628906 -17.796875 28.734375 -17.796875 L 25.40625 -17.796875 L 27.515625 -26.875 L 29.828125 -26.875 C 30.722656 -26.875 31.5625 -27.203125 32.34375 -27.859375 C 33.132812 -28.523438 33.640625 -29.351562 33.859375 -30.34375 L 33.984375 -30.96875 C 34.191406 -31.914062 34.0625 -32.71875 33.59375 -33.375 C 33.125 -34.039062 32.441406 -34.375 31.546875 -34.375 Z M 29.25 -34.375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(237.285815, 211.874986)"><g><path d="M 44.609375 -33.796875 L 25.015625 -1.03125 C 24.847656 -0.769531 24.703125 -0.507812 24.578125 -0.25 L 23.671875 -16.890625 L 32.0625 -33.796875 Z M 21.25 4.609375 C 19.03125 7.460938 16.585938 9.644531 13.921875 11.15625 C 11.253906 12.675781 8.148438 13.4375 4.609375 13.4375 C 1.835938 13.4375 -0.53125 13.050781 -2.5 12.28125 L -0.765625 4.671875 L 4.34375 4.671875 C 7.550781 4.671875 9.816406 3.113281 11.140625 0 L 5.5 -33.796875 L 19.203125 -33.796875 Z M 21.25 4.609375 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(276.387414, 211.874986)"><g><pathLine truncated

Before

Width:  |  Height:  |  Size: 6.6 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32"
preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228">
<path fill="#00D8FF"
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z" />
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-69
View File
@@ -1,69 +0,0 @@
import { useRouteError, Link } from "react-router-dom"
/**
* Error page component that displays when routing errors occur.
* Uses React Router's useRouteError hook to get error information.
*/
export default function ErrorPage() {
const error = useRouteError() as Error & { statusText?: string; status?: number }
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div className="text-center">
{/* Error Icon */}
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<svg
className="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
{/* Error Title */}
<h1 className="mt-4 text-2xl font-bold text-gray-900">
页面出现错误
</h1>
{/* Error Message */}
<div className="mt-4">
<p className="text-sm text-gray-600">
{error?.statusText ?? error?.message ?? "发生了未预期的错误"}
</p>
{error?.status && (
<p className="text-xs text-gray-500 mt-1">
错误代码:{error.status}
</p>
)}
</div>
{/* Action Buttons */}
<div className="mt-6 flex flex-col space-y-3">
<Link
to="/"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
返回首页
</Link>
<button
onClick={() => window.history.back()}
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
返回上一页
</button>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,58 +0,0 @@
import { useState } from "react"
import { App, Form, Modal } from "antd"
import { FirearmApi } from "@/api"
import FirearmForm from "@/components/firearm-form"
import { AddFirearmRequest, Firearm } from "@/types"
interface FirearmCreateModalProps {
open: boolean
onCancel: () => void
onSuccess: (firearm: Firearm) => void
}
function normalizeRequest(values: AddFirearmRequest): AddFirearmRequest {
return {
...values,
review: values.review?.trim() || null,
}
}
export default function FirearmCreateModal({ open, onCancel, onSuccess }: FirearmCreateModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<AddFirearmRequest>()
const [loading, setLoading] = useState(false)
async function onFinish(values: AddFirearmRequest) {
setLoading(true)
try {
const firearm = await FirearmApi.addFirearm(normalizeRequest(values))
message.success("武器创建成功")
form.resetFields()
onSuccess(firearm)
} catch {
message.error("武器创建失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="新建武器"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="创建"
cancelText="取消"
confirmLoading={loading}
destroyOnHidden
afterOpenChange={(visible) => {
if (!visible) {
form.resetFields()
}
}}>
<FirearmForm form={form} onFinish={onFinish} />
</Modal>
)
}
@@ -1,67 +0,0 @@
import { useEffect, useState } from "react"
import { App, Form, Modal } from "antd"
import { FirearmApi } from "@/api"
import FirearmForm from "@/components/firearm-form"
import { AddFirearmRequest, Firearm } from "@/types"
interface FirearmEditModalProps {
open: boolean
firearm: Firearm | null
onCancel: () => void
onSuccess: (firearm: Firearm) => void
}
function normalizeRequest(values: AddFirearmRequest): AddFirearmRequest {
return {
...values,
review: values.review?.trim() || null,
}
}
export default function FirearmEditModal({ open, firearm, onCancel, onSuccess }: FirearmEditModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<AddFirearmRequest>()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open || !firearm) {
return
}
const { id: _id, ...editableValues } = firearm
form.setFieldsValue(editableValues)
}, [open, firearm, form])
async function onFinish(values: AddFirearmRequest) {
if (!firearm) {
return
}
setLoading(true)
try {
const updated = await FirearmApi.editFirearm(firearm.id, normalizeRequest(values))
message.success("武器更新成功")
onSuccess(updated)
} catch {
message.error("武器更新失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="编辑武器"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="保存"
cancelText="取消"
confirmLoading={loading}
destroyOnHidden
>
<FirearmForm form={form} onFinish={onFinish} />
</Modal>
)
}
-101
View File
@@ -1,101 +0,0 @@
import { Form, Input, InputNumber, Select } from "antd"
import { AddFirearmRequest, FirearmType } from "@/types"
import calibres from "@/constant/calibres.json"
const firearmTypeText: Record<FirearmType, string> = {
RIFLE: "步枪",
SUB_MACHINE_GUN: "冲锋枪",
SHOTGUN: "霰弹枪",
LIGHT_MACHINE_GUN: "轻机枪",
DESIGNATED_MARKSMAN_RIFLE: "射手步枪",
SNIPER_RIFLE: "狙击步枪",
PISTOL: "手枪",
SPECIAL: "特殊",
}
const calibreOptions = calibres.map((calibre) => ({
value: calibre,
label: calibre,
}))
interface FirearmFormProps {
form: ReturnType<typeof Form.useForm<AddFirearmRequest>>[0]
onFinish: (values: AddFirearmRequest) => void
}
export default function FirearmForm({ form, onFinish }: FirearmFormProps) {
return (
<Form<AddFirearmRequest> form={form} layout="vertical" onFinish={onFinish} requiredMark={false}>
<Form.Item<AddFirearmRequest>
name="name"
label="武器名称"
rules={[{ required: true, message: "请输入武器名称" }]}
>
<Input placeholder="请输入武器名称" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="type"
label="武器类型"
rules={[{ required: true, message: "请选择武器类型" }]}
>
<Select
placeholder="请选择武器类型"
options={Object.entries(firearmTypeText).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item<AddFirearmRequest>
name="level"
label="武器输出等级"
rules={[{ required: true, message: "请输入武器输出等级" }]}
>
<Input placeholder="例如:T0" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="calibre"
label="子弹口径"
rules={[{ required: true, message: "请选择子弹口径" }]}
>
<Select
placeholder="请选择子弹口径"
showSearch
optionFilterProp="label"
options={calibreOptions}
/>
</Form.Item>
<Form.Item<AddFirearmRequest>
name="fireRate"
label="射速(每分钟发数)"
rules={[{ required: true, message: "请输入射速" }]}
>
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入射速" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="armourDamage"
label="甲伤"
rules={[{ required: true, message: "请输入甲伤" }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入甲伤" />
</Form.Item>
<Form.Item<AddFirearmRequest>
name="bodyDamage"
label="肉伤"
rules={[{ required: true, message: "请输入肉伤" }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入肉伤" />
</Form.Item>
<Form.Item<AddFirearmRequest> name="review" label="描述">
<Input.TextArea rows={4} placeholder="可选:补充武器特点或使用建议" />
</Form.Item>
</Form>
)
}
@@ -1,97 +0,0 @@
import { useEffect, useState } from "react"
import { App, Form, Modal } from "antd"
import { ModificationApi } from "@/api"
import ModificationForm from "@/components/modification-form"
import { Modification, ModificationRequest } from "@/types"
interface ModificationCreateModalProps {
open: boolean
defaultFirearmId?: number
lockedFirearmId?: number
onCancel: () => void
onSuccess: (modification: Modification) => void
}
function normalizeRequest(values: ModificationRequest): ModificationRequest {
return {
firearmId: values.firearmId,
name: values.name.trim(),
code: values.code.trim(),
tags: values.tags?.map((tag) => tag.trim()).filter(Boolean) || [],
note: values.note?.trim() || undefined,
author: values.author?.trim() || undefined,
videoUrl: values.videoUrl?.trim() || undefined,
accessories: (values.accessories || []).map((accessory) => ({
slotName: accessory.slotName.trim(),
accessoryName: accessory.accessoryName.trim(),
tunings: (accessory.tunings || []).map((tuning) => ({
tuningName: tuning.tuningName.trim(),
tuningValue: tuning.tuningValue,
})),
})),
}
}
export default function ModificationCreateModal({
open,
defaultFirearmId,
lockedFirearmId,
onCancel,
onSuccess,
}: ModificationCreateModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<ModificationRequest>()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) {
return
}
form.setFieldsValue({
firearmId: lockedFirearmId ?? defaultFirearmId,
accessories: [],
tags: [],
})
}, [open, defaultFirearmId, lockedFirearmId, form])
async function onFinish(values: ModificationRequest) {
setLoading(true)
try {
const modification = await ModificationApi.addModification(
normalizeRequest({
...values,
firearmId: lockedFirearmId ?? values.firearmId,
})
)
message.success("改枪码创建成功")
form.resetFields()
onSuccess(modification)
} catch {
message.error("改枪码创建失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="新建改枪"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="创建"
cancelText="取消"
confirmLoading={loading}
width={820}
destroyOnHidden
afterOpenChange={(visible) => {
if (!visible) {
form.resetFields()
}
}}
>
<ModificationForm form={form} onFinish={onFinish} lockFirearmId={lockedFirearmId} />
</Modal>
)
}
@@ -1,100 +0,0 @@
import { useEffect, useState } from "react"
import { App, Form, Modal } from "antd"
import { ModificationApi } from "@/api"
import ModificationForm from "@/components/modification-form"
import { Modification, ModificationRequest } from "@/types"
interface ModificationEditModalProps {
open: boolean
modification: Modification | null
lockedFirearmId?: number
onCancel: () => void
onSuccess: (modification: Modification) => void
}
function normalizeRequest(values: ModificationRequest): ModificationRequest {
return {
firearmId: values.firearmId,
name: values.name.trim(),
code: values.code.trim(),
tags: values.tags?.map((tag) => tag.trim()).filter(Boolean) || [],
note: values.note?.trim() || undefined,
author: values.author?.trim() || undefined,
videoUrl: values.videoUrl?.trim() || undefined,
accessories: (values.accessories || []).map((accessory) => ({
slotName: accessory.slotName.trim(),
accessoryName: accessory.accessoryName.trim(),
tunings: (accessory.tunings || []).map((tuning) => ({
tuningName: tuning.tuningName.trim(),
tuningValue: tuning.tuningValue,
})),
})),
}
}
export default function ModificationEditModal({
open,
modification,
lockedFirearmId,
onCancel,
onSuccess,
}: ModificationEditModalProps) {
const { message } = App.useApp()
const [form] = Form.useForm<ModificationRequest>()
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open || !modification) {
return
}
const { id: _id, ...editableValues } = modification
form.setFieldsValue({
...editableValues,
firearmId: lockedFirearmId ?? editableValues.firearmId,
tags: editableValues.tags || [],
accessories: editableValues.accessories || [],
})
}, [open, modification, lockedFirearmId, form])
async function onFinish(values: ModificationRequest) {
if (!modification) {
return
}
setLoading(true)
try {
const updated = await ModificationApi.editModification(
modification.id,
normalizeRequest({
...values,
firearmId: lockedFirearmId ?? values.firearmId,
})
)
message.success("改枪码更新成功")
onSuccess(updated)
} catch {
message.error("改枪码更新失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<Modal
title="编辑改枪"
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="保存"
cancelText="取消"
confirmLoading={loading}
width={820}
destroyOnHidden
>
<ModificationForm form={form} onFinish={onFinish} lockFirearmId={lockedFirearmId} />
</Modal>
)
}
-217
View File
@@ -1,217 +0,0 @@
import { useEffect, useMemo, useState } from "react"
import { FirearmApi } from "@/api"
import slotNames from "@/constant/slots.json"
import tuningNames from "@/constant/tunings.json"
import { Firearm, ModificationRequest } from "@/types"
import { AutoComplete, Button, Card, Form, Input, InputNumber, Select, Space } from "antd"
interface ModificationFormProps {
form: ReturnType<typeof Form.useForm<ModificationRequest>>[0]
onFinish: (values: ModificationRequest) => void
lockFirearmId?: number
}
const slotOptions = slotNames.map((slotName) => ({ value: slotName }))
const tuningOptions = tuningNames.map((tuningName) => ({ value: tuningName }))
export default function ModificationForm({ form, onFinish, lockFirearmId }: ModificationFormProps) {
const [firearmOptions, setFirearmOptions] = useState<Array<{ value: number; label: string }>>([])
const [firearmLoading, setFirearmLoading] = useState(false)
useEffect(() => {
let active = true
async function loadAllFirearms() {
setFirearmLoading(true)
try {
const allFirearms: Firearm[] = []
let page = 0
let totalPages = 1
while (page < totalPages) {
const paged = await FirearmApi.getFirearms({
page,
size: 100,
sortBy: "id",
direction: "ASC",
})
allFirearms.push(...paged.items)
totalPages = paged.totalPages
page += 1
}
if (!active) {
return
}
setFirearmOptions(
allFirearms.map((firearm) => ({
value: firearm.id,
label: `${firearm.name}`,
}))
)
} finally {
if (active) {
setFirearmLoading(false)
}
}
}
void loadAllFirearms()
return () => {
active = false
}
}, [])
const mergedFirearmOptions = useMemo(() => {
if (
lockFirearmId === undefined ||
firearmOptions.some((option) => option.value === lockFirearmId)
) {
return firearmOptions
}
return [{ value: lockFirearmId, label: `武器 ID: ${lockFirearmId}` }, ...firearmOptions]
}, [firearmOptions, lockFirearmId])
return (
<Form<ModificationRequest>
form={form}
layout="vertical"
onFinish={onFinish}
requiredMark={false}>
<Form.Item<ModificationRequest>
name="firearmId"
label="武器"
rules={[{ required: true, message: "请输入武器" }]}>
<Select<number>
className="w-full"
placeholder="请选择武器"
options={mergedFirearmOptions}
loading={firearmLoading}
disabled={lockFirearmId !== undefined}
showSearch={{
filterOption: (input, option) => {
const labelText = String(option?.label ?? "")
return labelText.toLowerCase().includes(input.toLowerCase())
},
}}
/>
</Form.Item>
<Form.Item<ModificationRequest>
name="name"
label="改装名称"
rules={[{ required: true, message: "请输入改装名称" }]}>
<Input placeholder="请输入改装名称" />
</Form.Item>
<Form.Item<ModificationRequest>
name="code"
label="改枪码"
rules={[{ required: true, message: "请输入改枪码" }]}>
<Input placeholder="请输入改枪码" />
</Form.Item>
<Form.Item<ModificationRequest> name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", " "]} placeholder="可选:输入后回车" />
</Form.Item>
<Form.Item<ModificationRequest> name="author" label="作者">
<Input placeholder="可选:请输入作者" />
</Form.Item>
<Form.Item<ModificationRequest> name="videoUrl" label="视频链接">
<Input placeholder="可选:请输入视频链接" />
</Form.Item>
<Form.Item<ModificationRequest> name="note" label="备注">
<Input.TextArea rows={3} placeholder="可选:补充说明" />
</Form.Item>
<Form.List name="accessories">
{(accessoryFields, { add: addAccessory, remove: removeAccessory }) => (
<div className="flex flex-col gap-4">
{accessoryFields.map((accessoryField) => (
<Card
key={accessoryField.key}
title={`配件 ${accessoryField.name + 1}`}
size="small"
extra={
<Button
danger
type="link"
size="small"
onClick={() => removeAccessory(accessoryField.name)}>
删除配件
</Button>
}>
<Form.Item
name={[accessoryField.name, "slotName"]}
label="槽位"
rules={[{ required: true, message: "请选择或输入槽位" }]}>
<AutoComplete options={slotOptions} placeholder="请选择或输入槽位" />
</Form.Item>
<Form.Item
name={[accessoryField.name, "accessoryName"]}
label="配件名称"
rules={[{ required: true, message: "请输入配件名称" }]}>
<Input placeholder="请输入配件名称" />
</Form.Item>
<Form.List name={[accessoryField.name, "tunings"]}>
{(tuningFields, { add: addTuning, remove: removeTuning }) => (
<div className="flex flex-col gap-3">
{tuningFields.map((tuningField) => (
<Space key={tuningField.key} align="start" className="w-full" wrap>
<Form.Item
name={[tuningField.name, "tuningName"]}
label="精校属性"
rules={[{ required: true, message: "请选择或输入精校属性" }]}>
<AutoComplete
options={tuningOptions}
placeholder="例如:后坐控制"
className="w-44"
/>
</Form.Item>
<Form.Item
name={[tuningField.name, "tuningValue"]}
label="精校值"
rules={[{ required: true, message: "请输入精校值" }]}>
<InputNumber className="w-32" placeholder="例如:0.35" />
</Form.Item>
<Button
type="link"
danger
className="mt-8"
onClick={() => removeTuning(tuningField.name)}>
删除
</Button>
</Space>
))}
<Button
type="dashed"
disabled={tuningFields.length >= 2}
onClick={() => addTuning({ tuningName: "", tuningValue: 0 })}>
添加精校
</Button>
</div>
)}
</Form.List>
</Card>
))}
<Button
variant="solid"
color="lime"
onClick={() => addAccessory({ slotName: "", accessoryName: "", tunings: [] })}>
添加配件
</Button>
</div>
)}
</Form.List>
</Form>
)
}
-23
View File
@@ -1,23 +0,0 @@
[
".338 Lap Mag",
".357 Magnum",
".45 ACP",
".50 AE",
".50 BMG",
"12 Gauge",
"12.7x55mm",
"5.45x39mm",
"5.56x45mm",
"5.7x28mm",
"5.8x42mm",
"6.8x51mm",
"7.62x39mm",
"7.62x51mm",
"7.62x54mm",
"9x19mm",
"9x39mm",
"4.6x30mm",
".300 BLK",
"箭矢",
"45-70 Govt"
]
-23
View File
@@ -1,23 +0,0 @@
[
"枪口",
"左导轨",
"右导轨",
"枪管",
"左贴片",
"右贴片",
"上导轨",
"上贴片",
"下导轨",
"瞄准镜",
"战术设备",
"增高座瞄具",
"侧瞄具",
"枪托",
"枪托套件",
"后握把",
"前握把",
"导轨脚架",
"弹匣座",
"弹匣",
"托腮板"
]
-1
View File
@@ -1 +0,0 @@
["安装位置", "厚度", "缩放倍率", "长度", "瞳距", "配重", "托腮板安装位置"]
-36
View File
@@ -1,36 +0,0 @@
@layer theme, base, antd, components, utilities;
@import 'tailwindcss';
html, body {
margin: 0;
padding: 0;
width: 100%;
}
#root {
width: 100%;
}
.mod-codes-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@media (min-width: 640px) {
.mod-codes-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.mod-codes-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.mod-codes-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
-13
View File
@@ -1,13 +0,0 @@
import { Outlet } from "react-router-dom"
/**
* Empty layout component that provides minimal structure.
* Useful for pages that need full control over their layout.
*/
export default function EmptyLayout() {
return (
<div className="min-h-screen">
<Outlet />
</div>
)
}
-106
View File
@@ -1,106 +0,0 @@
import { Outlet, Link } from "react-router-dom"
import { useMemo } from "react"
import dayjs from "dayjs"
import { Dropdown } from "antd"
import { AuthApi } from "@/api"
import { useAppDispatch, useAppSelector } from "@/store"
import { clearCurrentUser } from "@/store/auth-slice"
/**
* Main application component that serves as the root layout.
* Uses React Router's Outlet to render child routes.
*/
export default function HeroLayout() {
const today = useMemo(() => dayjs(), [])
const user = useAppSelector((state) => state.auth.user)
const dispatch = useAppDispatch()
async function handleLogout() {
try {
await AuthApi.logout()
} finally {
dispatch(clearCurrentUser())
}
}
return (
<div className="bg-gray-50">
{/* Navigation Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold text-gray-900">
三角洲行动改枪码库
</h1>
</div>
<nav className="flex space-x-8">
<Link
to="/firearms"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
武器列表
</Link>
<Link
to="/mod-codes"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
改枪码
</Link>
{user ? (
<Dropdown
trigger={["hover"]}
menu={{
items: [
{
key: "logout",
label: "退出登录",
danger: true,
onClick: handleLogout,
},
],
}}
>
<span className="cursor-pointer text-gray-700 px-3 py-2 rounded-md text-sm font-medium">
{user.username}
</span>
</Dropdown>
) : (
<Link
to="/login"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
登录
</Link>
)}
<a
href="https://github.com/zihluwang/delta-force-firearm-modification-codes"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
GitHub
</a>
</nav>
</div>
</div>
</header>
{/* Main Content Area */}
<main className="max-w-screen-2xl mx-auto py-6 sm:px-6 lg:px-10">
<div className="px-4 py-6 sm:px-0">
<Outlet />
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t">
<div className="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-10">
<p className="text-center text-sm text-gray-500">
© 2024-{today.year()} Zihlu Wang 和 OnixByte。使用 React 与 TypeScript 构建。
</p>
</div>
</footer>
</div>
)
}
-35
View File
@@ -1,35 +0,0 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { Provider } from "react-redux"
import { PersistGate } from "redux-persist/integration/react"
import { App as AntApp, ConfigProvider as AntConfigProvider } from "antd"
import { StyleProvider as AntStyleProvider } from "@ant-design/cssinjs"
import AntSimplifiedChinese from "antd/locale/zh_CN"
import router from "@/router"
import store, { persistor } from "@/store"
import "./index.css"
/**
* Main application entry point.
* Sets up the React app with React Router.
*/
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<AntStyleProvider layer>
<AntConfigProvider
locale={AntSimplifiedChinese}
button={{
autoInsertSpace: false,
}}>
<AntApp className="h-full w-full">
<RouterProvider router={router} />
</AntApp>
</AntConfigProvider>
</AntStyleProvider>
</PersistGate>
</Provider>
</StrictMode>
)
@@ -0,0 +1,13 @@
package com.onixbyte.deltaforceguide;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DeltaForceGuideApplication {
public static void main(String[] args) {
SpringApplication.run(DeltaForceGuideApplication.class, args);
}
}
@@ -0,0 +1,70 @@
package com.onixbyte.deltaforceguide.client;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.properties.TokenProperties;
import com.onixbyte.deltaforceguide.utils.DateTimeUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
public class TokenClient {
private final Algorithm algorithm;
private final TokenProperties tokenProperties;
private final JWTVerifier verifier;
/**
* Constructs a new TokenClient with the necessary algorithm and token properties.
*
* @param algorithm the signing algorithm used to secure the JWT
* @param tokenProperties the configuration properties for the token, such as issuer and
* validity period
*/
@Autowired
public TokenClient(
Algorithm algorithm,
TokenProperties tokenProperties,
JWTVerifier verifier
) {
this.algorithm = algorithm;
this.tokenProperties = tokenProperties;
this.verifier = verifier;
}
/**
* Generate a JSON Web Token to the current user.
*
* @param user the current user for whom the token is being generated
* @return a JWT string
*/
public String generateToken(User user) {
var issuedAt = LocalDateTime.now();
var expiresAt = issuedAt.plus(tokenProperties.validTime());
return JWT.create()
.withSubject(user.getUsername())
.withIssuer(tokenProperties.issuer())
.withIssuedAt(DateTimeUtil.asInstant(issuedAt))
.withExpiresAt(DateTimeUtil.asInstant(expiresAt))
.sign(algorithm);
}
/**
* Verify and decode token.
*
* @param token a JWT token
* @return information included in the given token
* @throws com.auth0.jwt.exceptions.JWTVerificationException if the token is invalid, such as
* expired, or not signed by
* specific server
*/
public DecodedJWT verifyToken(String token) {
return verifier.verify(token);
}
}
@@ -0,0 +1,102 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.shared.JacksonRedisSerialiser;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import java.time.Duration;
/**
* Configuration class for Redis-based caching components.
* <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,38 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.properties.CorsProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
import java.util.stream.Stream;
@Configuration
@EnableConfigurationProperties({CorsProperties.class})
public class CorsConfig {
@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,20 @@
package com.onixbyte.deltaforceguide.config;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.onixbyte.deltaforceguide.shared.JacksonModules;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomiser() {
return (builder) -> {
builder.modules(JacksonModules.DATE_TIME_MODULE);
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
};
}
}
@@ -0,0 +1,10 @@
package com.onixbyte.deltaforceguide.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"})
public class MyBatisConfig {
}
@@ -0,0 +1,76 @@
package com.onixbyte.deltaforceguide.config;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter;
import com.onixbyte.deltaforceguide.properties.CookieProperties;
import com.onixbyte.deltaforceguide.properties.TokenProperties;
import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity,
CorsConfigurationSource corsConfigurationSource,
TokenAuthenticationFilter tokenAuthenticationFilter
) throws Exception {
return httpSecurity
.cors((cors) -> cors
.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((customiser) -> customiser
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((customiser) -> customiser
.anyRequest().permitAll()
)
.addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
) {
return new ProviderManager(
usernamePasswordAuthenticationProvider
);
}
@Bean
public Algorithm algorithm(TokenProperties properties) {
return Algorithm.HMAC256(properties.secret());
}
@Bean
public JWTVerifier verifier(Algorithm algorithm, TokenProperties tokenProperties) {
return JWT.require(algorithm)
.withIssuer(tokenProperties.issuer())
.build();
}
}
@@ -0,0 +1,10 @@
package com.onixbyte.deltaforceguide.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Configuration
@EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"})
public class SpringDataConfig {
}
@@ -0,0 +1,58 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
import com.onixbyte.deltaforceguide.domain.dto.UserResponse;
import com.onixbyte.deltaforceguide.client.TokenClient;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.AuthService;
import com.onixbyte.deltaforceguide.service.CookieService;
import com.onixbyte.deltaforceguide.shared.CookieName;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpHeaders;
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;
import java.time.Duration;
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
private final TokenClient tokenClient;
private final CookieService cookieService;
public AuthController(AuthService authService, TokenClient tokenClient, CookieService cookieService) {
this.authService = authService;
this.tokenClient = tokenClient;
this.cookieService = cookieService;
}
@Operation(description = "用户登录")
@PostMapping("/login")
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
var user = authService.login(request);
var accessToken = tokenClient.generateToken(user);
var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
.body(UserResponse.from(user));
}
@RequiresAuth
@Operation(description = "退出登录")
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
var expiredCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO);
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
.build();
}
}
@@ -0,0 +1,27 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.deltaforceguide.service.DailyPasswordService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "每日密码", description = "获取每日密码信息")
@RestController
@RequestMapping("/daily-passwords")
public class DailyPasswordController {
private final DailyPasswordService dailyPasswordService;
public DailyPasswordController(DailyPasswordService dailyPasswordService) {
this.dailyPasswordService = dailyPasswordService;
}
@Operation(description = "获取当日的每日密码数据,该数据将被缓存一天")
@GetMapping
public DailyPasswordResponse getDailyPassword() {
return dailyPasswordService.getDailyPassword();
}
}
@@ -0,0 +1,67 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.FirearmRequest;
import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse;
import com.onixbyte.deltaforceguide.domain.dto.PageResponse;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.FirearmService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.*;
@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,19 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.ErrorResponse;
import com.onixbyte.deltaforceguide.exeption.BizException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<ErrorResponse> handleBizException(BizException exception) {
var status = exception.getStatus();
return ResponseEntity.status(status)
.body(new ErrorResponse(exception.getMessage()));
}
}
@@ -0,0 +1,96 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchCreateRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse;
import com.onixbyte.deltaforceguide.domain.dto.PageResponse;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.ModificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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;
@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(@Valid @RequestBody ModificationRequest request) {
return modificationService.create(request);
}
@RequiresAuth
@Operation(description = "批量创建改装")
@PostMapping("/batch")
public List<ModificationResponse> batchCreate(@Valid @RequestBody ModificationBatchCreateRequest request) {
return modificationService.batchCreate(request.modifications());
}
@RequiresAuth
@Operation(description = "修改指定改装")
@PutMapping("/{id}")
public ModificationResponse update(@PathVariable Long id, @Valid @RequestBody ModificationRequest request) {
return modificationService.update(id, request);
}
@RequiresAuth
@Operation(description = "删除指定改装")
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
modificationService.delete(id);
}
@RequiresAuth
@Operation(description = "批量删除改装")
@DeleteMapping("/batch-delete")
@Validated
public void batchDelete(@RequestParam List<@Positive Long> ids) {
modificationService.batchDelete(ids);
}
}
@@ -0,0 +1,29 @@
package com.onixbyte.deltaforceguide.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.onixbyte.deltaforceguide.service.ModificationService;
import java.util.List;
@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,21 @@
package com.onixbyte.deltaforceguide.domain.converter;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter
public class FirearmTypeConverter implements AttributeConverter<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,20 @@
package com.onixbyte.deltaforceguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;
public record AccessoryRequest(
@NotBlank(message = "插槽名称不能为空")
String slotName,
@NotBlank(message = "配件名称不能为空")
String accessoryName,
List<@Valid TuningRequest> tunings
) {
public List<TuningRequest> tunings() {
return tunings == null ? new ArrayList<>() : tunings;
}
}
@@ -0,0 +1,22 @@
package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import java.util.List;
public record AccessoryResponse(
String slotName,
String accessoryName,
List<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,7 @@
package com.onixbyte.deltaforceguide.domain.dto;
public record DailyPassword(
String mapName,
String password
) {
}
@@ -0,0 +1,14 @@
package com.onixbyte.deltaforceguide.domain.dto;
import java.time.LocalDateTime;
import java.util.List;
public record DailyPasswordData(
String updateDate,
Integer totalCount,
List<DailyPassword> passwords,
String source,
LocalDateTime lastUpdated,
Long timestamp
) {
}
@@ -0,0 +1,7 @@
package com.onixbyte.deltaforceguide.domain.dto;
public record DailyPasswordMetadata(
String version,
String author
) {
}
@@ -0,0 +1,9 @@
package com.onixbyte.deltaforceguide.domain.dto;
public record DailyPasswordResponse(
String status,
String message,
DailyPasswordData data,
DailyPasswordMetadata metadata
) {
}
@@ -0,0 +1,7 @@
package com.onixbyte.deltaforceguide.domain.dto;
public record ErrorResponse(
String message
) {
}
@@ -0,0 +1,15 @@
package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
public record FirearmRequest(
String name,
FirearmType type,
String level,
String calibre,
Integer fireRate,
Integer armourDamage,
Integer bodyDamage,
String review
) {
}
@@ -0,0 +1,31 @@
package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
public record FirearmResponse(
Long id,
String name,
FirearmType type,
String level,
String 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.deltaforceguide.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@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.deltaforceguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record ModificationBatchCreateRequest(
@NotEmpty(message = "批量创建列表不能为空")
List<@Valid ModificationRequest> modifications
) {
}
@@ -0,0 +1,13 @@
package com.onixbyte.deltaforceguide.domain.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
public record ModificationBatchDeleteRequest(
@NotEmpty(message = "批量删除ID列表不能为空")
List<@Positive(message = "ID必须为正数") Long> ids
) {
}
@@ -0,0 +1,33 @@
package com.onixbyte.deltaforceguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.util.ArrayList;
import java.util.List;
public record ModificationRequest(
@NotNull(message = "武器ID不能为空")
@Positive(message = "武器ID必须为正数")
Long firearmId,
@NotBlank(message = "改装名称不能为空")
String name,
@NotBlank(message = "改装代码不能为空")
String code,
List<@NotBlank(message = "标签不能为空") String> tags,
List<@Valid AccessoryRequest> accessories,
String note,
String author,
String videoUrl
) {
public List<String> tags() {
return tags == null ? new ArrayList<>() : tags;
}
public List<AccessoryRequest> accessories() {
return accessories == null ? new ArrayList<>() : accessories;
}
}
@@ -0,0 +1,34 @@
package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Modification;
import java.util.List;
public record ModificationResponse(
Long id,
Long firearmId,
String name,
String code,
List<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,24 @@
package com.onixbyte.deltaforceguide.domain.dto;
import org.springframework.data.domain.Page;
import java.util.List;
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,13 @@
package com.onixbyte.deltaforceguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record TuningRequest(
@NotBlank(message = "调校项名称不能为空")
String tuningName,
@NotNull(message = "调校值不能为空")
Double tuningValue
) {
}
@@ -0,0 +1,16 @@
package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
public record TuningResponse(
String tuningName,
Double tuningValue
) {
public static TuningResponse from(Tuning tuning) {
return new TuningResponse(
tuning.getTuningName(),
tuning.getTuningValue()
);
}
}
@@ -0,0 +1,17 @@
package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.User;
public record UserResponse(
Long id,
String username,
String email
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail()
);
}
}
@@ -0,0 +1,48 @@
package com.onixbyte.deltaforceguide.domain.entity;
import java.util.ArrayList;
import java.util.List;
public class Accessory {
private String slotName;
private String accessoryName;
private List<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);
}
}
@@ -0,0 +1,227 @@
package com.onixbyte.deltaforceguide.domain.entity;
import com.onixbyte.deltaforceguide.domain.converter.FirearmTypeConverter;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "firearm")
public class Firearm {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "type", nullable = false)
@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,206 @@
package com.onixbyte.deltaforceguide.domain.entity;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(
name = "modification",
indexes = {
@Index(name = "idx_modification_firearm_id", columnList = "firearm_id")
}
)
public class Modification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "firearm_id", nullable = false, foreignKey = @ForeignKey(name = "fk_modification_firearm"))
private Firearm firearm;
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "code", nullable = false, length = 64)
private String code;
@Type(JsonType.class)
@Column(name = "tags", columnDefinition = "jsonb")
private List<String> tags = new ArrayList<>();
@Type(JsonType.class)
@Column(name = "accessories", columnDefinition = "jsonb")
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;
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 static Builder builder() {
return new Builder();
}
public static class Builder {
private Long id;
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;
public Builder id(Long id) {
this.id = id;
return this;
}
public Builder firearm(Firearm firearm) {
this.firearm = firearm;
return this;
}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder code(String code) {
this.code = code;
return this;
}
public Builder tags(List<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 Modification build() {
Modification modification = new Modification();
modification.id = this.id;
modification.firearm = this.firearm;
modification.name = this.name;
modification.code = this.code;
modification.tags = this.tags == null ? new ArrayList<>() : this.tags;
modification.accessories = this.accessories == null ? new ArrayList<>() : this.accessories;
modification.note = this.note;
modification.author = this.author;
modification.videoUrl = this.videoUrl;
return modification;
}
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.deltaforceguide.domain.entity;
public class Tuning {
private String tuningName;
private Double tuningValue;
public Tuning() {
}
public String getTuningName() {
return tuningName;
}
public void setTuningName(String tuningName) {
this.tuningName = tuningName;
}
public Double getTuningValue() {
return tuningValue;
}
public void setTuningValue(Double tuningValue) {
this.tuningValue = tuningValue;
}
}
@@ -0,0 +1,114 @@
package com.onixbyte.deltaforceguide.domain.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "app_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false)
private String username;
@Column(name = "email", nullable = false)
private String email;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<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 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 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 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.credentials = this.credentials == null ? new ArrayList<>() : this.credentials;
return user;
}
}
}
@@ -0,0 +1,138 @@
package com.onixbyte.deltaforceguide.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "app_user_credential")
public class UserCredential {
@EmbeddedId
@AttributeOverrides({
@AttributeOverride(name = "userId", column = @Column(name = "user_id")),
@AttributeOverride(name = "provider", column = @Column(name = "provider"))
})
private UserCredentialId id = new UserCredentialId();
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user"))
private User user;
@Column(name = "credential", nullable = false, length = 255)
private String credential;
public UserCredentialId getId() {
return id;
}
public void setId(UserCredentialId id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
if (this.id == null) {
this.id = new UserCredentialId();
}
this.id.setUserId(user == null ? null : user.getId());
}
public Long getUserId() {
return id == null ? null : id.getUserId();
}
public void setUserId(Long userId) {
if (this.id == null) {
this.id = new UserCredentialId();
}
this.id.setUserId(userId);
}
public String getProvider() {
return id == null ? null : id.getProvider();
}
public void setProvider(String provider) {
if (this.id == null) {
this.id = new UserCredentialId();
}
this.id.setProvider(provider);
}
public String getCredential() {
return credential;
}
public void setCredential(String credential) {
this.credential = credential;
}
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,76 @@
package com.onixbyte.deltaforceguide.domain.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
@Embeddable
public class UserCredentialId implements Serializable {
private Long userId;
private String provider;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UserCredentialId that = (UserCredentialId) o;
return Objects.equals(userId, that.userId) && Objects.equals(provider, that.provider);
}
@Override
public int hashCode() {
return Objects.hash(userId, provider);
}
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,37 @@
package com.onixbyte.deltaforceguide.enumeration;
public enum FirearmType {
RIFLE(0),
SUB_MACHINE_GUN(1),
SHOTGUN(2),
LIGHT_MACHINE_GUN(3),
DESIGNATED_MARKSMAN_RIFLE(4),
SNIPER_RIFLE(5),
PISTOL(6),
SPECIAL(7);
private final int code;
FirearmType(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static FirearmType fromCode(Integer code) {
if (code == null) {
return null;
}
for (FirearmType type : values()) {
if (type.code == code) {
return type;
}
}
throw new IllegalArgumentException("Unknown FirearmType code: " + code);
}
}
@@ -0,0 +1,35 @@
package com.onixbyte.deltaforceguide.exeption;
import org.springframework.http.HttpStatus;
public class BizException extends RuntimeException {
/**
* The HTTP status code associated with this business exception.
* <p>
* This status code indicates the appropriate HTTP response status that should be returned to
* clients when this exception occurs. It enables consistent error handling across
* REST API endpoints.
*/
private final HttpStatus status;
public BizException(String message) {
super(message);
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
}
public BizException(HttpStatus status, String message) {
super(message);
this.status = status;
}
/**
* Returns the HTTP status code associated with this business exception.
*
* @return the HTTP status code that should be used in the error response
*/
public HttpStatus getStatus() {
return status;
}
}
@@ -0,0 +1,102 @@
package com.onixbyte.deltaforceguide.filter;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.onixbyte.deltaforceguide.client.TokenClient;
import com.onixbyte.deltaforceguide.exeption.BizException;
import com.onixbyte.deltaforceguide.manager.UserManager;
import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication;
import com.onixbyte.deltaforceguide.service.CookieService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.util.WebUtils;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
private static final Duration ACCESS_TOKEN_RENEW_THRESHOLD = Duration.ofMinutes(5);
private final UserManager userManager;
private final TokenClient tokenClient;
private final CookieService cookieService;
private final HandlerExceptionResolver handlerExceptionResolver;
public TokenAuthenticationFilter(
UserManager userManager,
TokenClient tokenClient,
CookieService cookieService,
HandlerExceptionResolver handlerExceptionResolver
) {
this.userManager = userManager;
this.tokenClient = tokenClient;
this.cookieService = cookieService;
this.handlerExceptionResolver = handlerExceptionResolver;
}
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
var token = Optional.ofNullable(WebUtils.getCookie(request, "AccessToken"))
.map(Cookie::getValue)
.orElse(null);
if (Objects.isNull(token) || token.isBlank()) {
filterChain.doFilter(request, response);
return;
}
try {
var decodedToken = tokenClient.verifyToken(token);
var username = decodedToken.getSubject();
var userWrapper = userManager.findByUsername(username);
if (userWrapper.isEmpty()) {
throw new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录");
}
var user = userWrapper.get();
var authentication = UsernamePasswordAuthentication.authenticated(user);
SecurityContextHolder.getContext().setAuthentication(authentication);
if (shouldRenew(decodedToken.getExpiresAt().toInstant())) {
var renewedToken = tokenClient.generateToken(user);
var renewedTokenCookie = cookieService.buildCookie("AccessToken", renewedToken);
response.addHeader(HttpHeaders.SET_COOKIE, renewedTokenCookie.toString());
}
filterChain.doFilter(request, response);
} catch (JWTVerificationException e) {
log.error("JWT verification failed.", e);
handlerExceptionResolver.resolveException(request, response, null,
new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录"));
} catch (BizException e) {
handlerExceptionResolver.resolveException(request, response, null, e);
}
}
private boolean shouldRenew(Instant expiresAt) {
return Duration.between(Instant.now(), expiresAt).compareTo(ACCESS_TOKEN_RENEW_THRESHOLD) < 0;
}
}
@@ -0,0 +1,37 @@
package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.properties.CookieProperties;
import org.springframework.boot.web.server.Cookie;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class CookieManager {
private final CookieProperties cookieProperties;
public CookieManager(CookieProperties cookieProperties) {
this.cookieProperties = cookieProperties;
}
public Boolean getHttpOnly() {
return cookieProperties.httpOnly();
}
public Boolean getSecure() {
return cookieProperties.secure();
}
public Cookie.SameSite getSameSite() {
return cookieProperties.sameSite();
}
public String getPath() {
return cookieProperties.path();
}
public Duration getMaxAge() {
return cookieProperties.maxAge();
}
}
@@ -0,0 +1,76 @@
package com.onixbyte.deltaforceguide.manager;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.deltaforceguide.exeption.BizException;
import com.onixbyte.deltaforceguide.shared.JacksonModules;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
@Component
public class DailyPasswordManager {
private static final String CACHE_KEY_PREFIX = "daily-password:";
private final RestClient restClient;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public DailyPasswordManager(
RestClient.Builder restClientBuilder,
RedisTemplate<String, Object> redisTemplate
) {
var snakeCaseMapper = new ObjectMapper();
snakeCaseMapper.setPropertyNamingStrategy(
PropertyNamingStrategies.SnakeCaseStrategy.INSTANCE);
snakeCaseMapper.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
snakeCaseMapper.registerModule(JacksonModules.DATE_TIME_MODULE);
this.restClient = restClientBuilder
.baseUrl("https://tmini.net/api")
.messageConverters(converters -> {
converters.removeIf(
MappingJackson2HttpMessageConverter.class::isInstance);
converters.add(
new MappingJackson2HttpMessageConverter(snakeCaseMapper));
})
.build();
this.redisTemplate = redisTemplate;
}
public DailyPasswordResponse getDailyPassword() {
var key = CACHE_KEY_PREFIX + LocalDate.now();
var cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return (DailyPasswordResponse) cached;
}
var response = restClient.get()
.uri((uriBuilder) -> uriBuilder
.path("/sjzmm")
.queryParam("ckey", "")
.queryParam("type", "json")
.build())
.retrieve()
.body(DailyPasswordResponse.class);
if (Objects.isNull(response)) {
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "暂无每日密码数据。");
}
redisTemplate.opsForValue().set(key, response, Duration.ofDays(1L));
return response;
}
}
@@ -0,0 +1,47 @@
package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.repository.UserCredentialRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Component
public class UserCredentialManager {
private final UserCredentialRepository userCredentialRepository;
public UserCredentialManager(UserCredentialRepository userCredentialRepository) {
this.userCredentialRepository = userCredentialRepository;
}
@Transactional(readOnly = true)
public List<UserCredential> findAllByUserId(Long userId) {
return userCredentialRepository.findAllByUserId(userId);
}
@Transactional(readOnly = true)
public Optional<UserCredential> findByUserIdAndProvider(Long userId, String provider) {
return userCredentialRepository.findByUserIdAndProvider(userId, provider);
}
@Transactional
public UserCredential save(UserCredential userCredential) {
return userCredentialRepository.save(userCredential);
}
@Transactional
public void deleteByUserIdAndProvider(Long userId, String provider) {
userCredentialRepository.deleteByUserIdAndProvider(userId, provider);
}
@Transactional
public void deleteAllByUserId(Long userId) {
userCredentialRepository.deleteAllByUserId(userId);
}
}
@@ -0,0 +1,54 @@
package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.repository.UserRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Component
public class UserManager {
private final UserRepository userRepository;
public UserManager(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
@Transactional(readOnly = true)
public List<User> findAll() {
return userRepository.findAll();
}
@Transactional(readOnly = true)
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
@Transactional(readOnly = true)
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
@Transactional
public User save(User user) {
return userRepository.save(user);
}
@Transactional
public void deleteById(Long id) {
userRepository.deleteById(id);
}
public Optional<User> findByUsernameOrEmail(String principal) {
return userRepository.findByUsernameOrEmail(principal);
}
}
@@ -0,0 +1,17 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.web.server.Cookie;
import java.time.Duration;
@ConfigurationProperties(prefix = "app.cookie")
public record CookieProperties(
@DefaultValue("true") Boolean httpOnly,
@DefaultValue("true") Boolean secure,
@DefaultValue("/") String path,
@DefaultValue("PT2H") Duration maxAge,
@DefaultValue("LAX") Cookie.SameSite sameSite
) {
}
@@ -0,0 +1,24 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.http.HttpMethod;
import java.time.Duration;
@ConfigurationProperties(prefix = "app.cors")
public record CorsProperties(
@DefaultValue({"Content-Type", "Authorization"})
String[] allowedHeaders,
@DefaultValue({"GET", "POST", "PUT", "PATCH", "DELETE"})
HttpMethod[] allowedMethods,
String[] allowedOrigins,
boolean allowCredentials,
boolean allowPrivateNetwork,
@DefaultValue("PT2H")
Duration maxAge,
@DefaultValue({"Content-Type", "Authorization"})
String[] exposedHeaders
) {
}
@@ -0,0 +1,13 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "app.jwt")
public record TokenProperties(
String issuer,
String secret,
Duration validTime
) {
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more