Compare commits

...

36 Commits

Author SHA1 Message Date
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
siujamo 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
siujamo 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
zihluwang 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
zihluwang 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
69 changed files with 2763 additions and 70 deletions
+2
View File
@@ -1,4 +1,6 @@
delta-force-guide-server.iml
build/
!.gitlab-ci.yml
!build/libs/*.jar
.idea/
.gradle
+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 ==='
+7
View File
@@ -143,6 +143,13 @@ test/
gradle-app.setting
.gradletasknamecache
### Claude Code
.claude/settings.local.json
.claude/memory/
.claude/plans/
.claude/worktrees/
.claude/scheduled_tasks.json
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
+58
View File
@@ -0,0 +1,58 @@
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
expire_in: 30 min
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
+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.
+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"]
+3 -1
View File
@@ -49,10 +49,12 @@ dependencies {
implementation(libs.flyway.core)
implementation(libs.flyway.postgresql)
implementation(libs.jackson.jsr310)
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16")
implementation(libs.spring.boot.starter.doc)
implementation(libs.spring.boot.starter.security)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.reactor.test)
testImplementation(libs.mybatis.starter.test)
testImplementation(libs.spring.security.test)
runtimeOnly(libs.postgres.driver)
testRuntimeOnly(libs.h2.database)
testRuntimeOnly(libs.junit.launcher)
+1 -1
View File
@@ -1 +1 @@
artefactVersion = 1.0.0
artefactVersion = 1.2.0
+6 -2
View File
@@ -18,6 +18,7 @@ mybatisVersion = "3.0.5"
jacksonVersion = "2.19.2"
hypersistenceVersion = "3.14.0"
springDependencyManagementVersion = "1.1.7"
springDocVersion = "2.8.16"
[libraries]
# General Utilities
@@ -57,16 +58,19 @@ spring-boot-configurationProcessor = { group = "org.springframework.boot", name
spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator" }
# Security & Auth
spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security", version.ref = "springBootVersion" }
spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" }
jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" }
# Spring Doc
spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" }
# Cloud Services
aws-sdk-bom = { group = "software.amazon.awssdk", name = "bom", version.ref = "awsSdkVersion" }
aws-sdk-s3 = { group = "software.amazon.awssdk", name = "s3" }
# Testing
spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "springBootVersion" }
spring-security-test = { group = "org.springframework.security", name = "spring-security-test", version.ref = "springSecurityVersion" }
spring-security-test = { group = "org.springframework.security", name = "spring-security-test" }
reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref = "reactorVersion" }
junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" }
mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" }
@@ -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);
}
}
@@ -2,46 +2,37 @@ package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.properties.CorsProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Optional;
import java.util.List;
import java.util.stream.Stream;
@Configuration
@EnableConfigurationProperties({CorsProperties.class})
public class CorsConfig implements WebMvcConfigurer {
public class CorsConfig {
private final CorsProperties properties;
public CorsConfig(CorsProperties properties) {
this.properties = properties;
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(toSafeArray(properties.allowedOrigins()))
.allowedHeaders(toSafeArray(properties.allowedHeaders()))
.allowedMethods(toHttpMethodNames(properties.allowedMethods()))
.allowCredentials(properties.allowCredentials())
.maxAge(properties.maxAge().toSeconds())
.exposedHeaders(toSafeArray(properties.exposedHeaders()));
}
private static String[] toSafeArray(String[] values) {
return values == null ? new String[0] : values;
}
private static String[] toHttpMethodNames(HttpMethod[] methods) {
return Optional.ofNullable(methods)
.stream()
.flatMap(Stream::of)
@Bean
public CorsConfigurationSource corsConfigurationSource(
CorsProperties properties
) {
var corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(properties.allowCredentials());
corsConfiguration.setAllowedOrigins(List.of(properties.allowedOrigins()));
corsConfiguration.setAllowedHeaders(List.of(properties.allowedHeaders()));
corsConfiguration.setAllowedMethods(Stream.of(properties.allowedMethods())
.map(HttpMethod::name)
.toList()
.toArray(String[]::new);
}
.toList());
corsConfiguration.setMaxAge(properties.maxAge());
corsConfiguration.setAllowPrivateNetwork(properties.allowPrivateNetwork());
corsConfiguration.setExposedHeaders(List.of(properties.exposedHeaders()));
var corsConfigurationSource = new UrlBasedCorsConfigurationSource();
corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
return corsConfigurationSource;
}
}
@@ -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,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();
}
}
@@ -1,21 +1,21 @@
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
@Validated
@Tag(name = "武器管理", description = "与武器有关的操作")
@RestController
@RequestMapping("/firearms")
public class FirearmController {
@@ -26,6 +26,8 @@ public class FirearmController {
this.firearmService = firearmService;
}
@Operation(description = "获取分页武器数据")
@Validated
@GetMapping
public PageResponse<FirearmResponse> pageQuery(
@RequestParam(defaultValue = "0") @Min(0) int page,
@@ -37,9 +39,29 @@ public class FirearmController {
return firearmService.pageQuery(type, PageRequest.of(page, size, Sort.by(direction, sortBy)));
}
@Operation(description = "获取指定武器的数据")
@GetMapping("/{id}")
public FirearmResponse queryById(@PathVariable Long id) {
return firearmService.queryById(id);
}
@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()));
}
}
@@ -1,21 +1,33 @@
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;
@Validated
import java.util.List;
@Tag(name = "改装管理", description = "对枪械改装的管理")
@RestController
@RequestMapping("/modifications")
public class ModificationController {
@@ -26,20 +38,59 @@ public class ModificationController {
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(defaultValue = "DESC") Sort.Direction direction,
@RequestParam(required = false) List<String> tags
) {
return modificationService.pageQuery(firearmId, PageRequest.of(page, size, Sort.by(direction, sortBy)));
return modificationService.pageQuery(firearmId, tags, PageRequest.of(page, size, Sort.by(direction, sortBy)));
}
@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,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
) {
}
@@ -8,6 +8,10 @@ public record FirearmResponse(
String name,
FirearmType type,
String level,
String calibre,
Integer fireRate,
Integer armourDamage,
Integer bodyDamage,
String review
) {
public static FirearmResponse from(Firearm firearm) {
@@ -16,6 +20,10 @@ public record FirearmResponse(
firearm.getName(),
firearm.getType(),
firearm.getLevel(),
firearm.getCalibre(),
firearm.getFireRate(),
firearm.getArmourDamage(),
firearm.getBodyDamage(),
firearm.getReview()
);
}
@@ -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;
}
}
@@ -10,6 +10,7 @@ public record ModificationResponse(
String name,
String code,
List<String> tags,
List<AccessoryResponse> accessories,
String note,
String author,
String videoUrl
@@ -21,6 +22,9 @@ public record ModificationResponse(
modification.getName(),
modification.getCode(),
modification.getTags(),
modification.getAccessories() == null
? List.of()
: modification.getAccessories().stream().map(AccessoryResponse::from).toList(),
modification.getNote(),
modification.getAuthor(),
modification.getVideoUrl()
@@ -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);
}
}
@@ -36,6 +36,18 @@ public class Firearm {
@Column(name = "review", columnDefinition = "TEXT")
private String review;
@Column(name = "calibre")
private String calibre;
@Column(name = "fire_rate")
private Integer fireRate;
@Column(name = "armour_damage")
private Integer armourDamage;
@Column(name = "body_damage")
private Integer bodyDamage;
@OneToMany(mappedBy = "firearm", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Modification> modifications = new ArrayList<>();
@@ -79,6 +91,38 @@ public class Firearm {
this.review = review;
}
public String getCalibre() {
return calibre;
}
public void setCalibre(String calibre) {
this.calibre = calibre;
}
public Integer getFireRate() {
return fireRate;
}
public void setFireRate(Integer fireRate) {
this.fireRate = fireRate;
}
public Integer getArmourDamage() {
return armourDamage;
}
public void setArmourDamage(Integer armourDamage) {
this.armourDamage = armourDamage;
}
public Integer getBodyDamage() {
return bodyDamage;
}
public void setBodyDamage(Integer bodyDamage) {
this.bodyDamage = bodyDamage;
}
public List<Modification> getModifications() {
return modifications;
}
@@ -96,5 +140,88 @@ public class Firearm {
this.modifications.remove(modification);
modification.setFirearm(null);
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long id;
private String name;
private FirearmType type;
private String level;
private String review;
private String calibre;
private Integer fireRate;
private Integer armourDamage;
private Integer bodyDamage;
private List<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;
}
}
}
@@ -1,17 +1,7 @@
package com.onixbyte.deltaforceguide.domain.entity;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.util.ArrayList;
@@ -41,9 +31,13 @@ public class Modification {
private String code;
@Type(JsonType.class)
@Column(name = "tags", columnDefinition = "json")
@Column(name = "tags", columnDefinition = "jsonb")
private List<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;
@@ -93,6 +87,22 @@ public class Modification {
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;
}
@@ -116,5 +126,81 @@ public class Modification {
public void setVideoUrl(String videoUrl) {
this.videoUrl = videoUrl;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long id;
private Firearm firearm;
private String name;
private String code;
private List<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,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,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
) {
}
@@ -1,12 +1,16 @@
package com.onixbyte.deltaforceguide.repository;
import com.onixbyte.deltaforceguide.domain.entity.Modification;
import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@@ -20,7 +24,22 @@ public interface ModificationRepository extends JpaRepository<Modification, Long
@Override
@EntityGraph(attributePaths = {"firearm"})
Optional<Modification> findById(Long id);
@NonNull
Optional<Modification> findById(@NonNull Long id);
@Query(value = """
SELECT * FROM modification m
WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId)
AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb))
""",
countQuery = """
SELECT count(*) FROM modification m
WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId)
AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb))
""",
nativeQuery = true)
Page<Modification> pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable);
@Query(value = "SELECT DISTINCT jsonb_array_elements_text(cast(tags as jsonb)) FROM modification WHERE (:firearmId IS NULL OR firearm_id = :firearmId)", nativeQuery = true)
List<String> findAllTags(@Param("firearmId") Long firearmId);
}
@@ -0,0 +1,52 @@
package com.onixbyte.deltaforceguide.repository;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.domain.entity.UserCredentialId;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserCredentialRepository extends JpaRepository<UserCredential, UserCredentialId> {
@EntityGraph(attributePaths = {"user"})
@Query("""
select uc
from UserCredential uc
where uc.user.id = :userId
""")
List<UserCredential> findAllByUserId(@Param("userId") Long userId);
@EntityGraph(attributePaths = {"user"})
@Query("""
select uc
from UserCredential uc
where uc.user.id = :userId
and uc.id.provider = :provider
""")
Optional<UserCredential> findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
@Modifying
@Query("""
delete from UserCredential uc
where uc.user.id = :userId
and uc.id.provider = :provider
""")
void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
@Modifying
@Query("""
delete from UserCredential uc
where uc.user.id = :userId
""")
void deleteAllByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,40 @@
package com.onixbyte.deltaforceguide.repository;
import com.onixbyte.deltaforceguide.domain.entity.User;
import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Override
@EntityGraph(attributePaths = {"credentials"})
@NonNull
Optional<User> findById(@NonNull Long id);
@EntityGraph(attributePaths = {"credentials"})
Optional<User> findByUsername(String username);
@EntityGraph(attributePaths = {"credentials"})
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
@EntityGraph(attributePaths = {"credentials"})
@Query("""
select u
from User u
where u.username = :principal
or u.email = :principal
""")
Optional<User> findByUsernameOrEmail(@Param("principal") String principal);
}
@@ -0,0 +1,14 @@
package com.onixbyte.deltaforceguide.security.annotation;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated()")
public @interface RequiresAuth {
}
@@ -0,0 +1,76 @@
package com.onixbyte.deltaforceguide.security.authentication;
import com.onixbyte.deltaforceguide.domain.entity.User;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
import java.util.List;
public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer {
private final String username;
private String password;
private boolean authenticated;
private User user;
private UsernamePasswordAuthentication(String username, String password, boolean authenticated, User user) {
this.username = username;
this.password = password;
this.authenticated = authenticated;
this.user = user;
}
public static UsernamePasswordAuthentication unauthenticated(String username, String password) {
return new UsernamePasswordAuthentication(username, password, false, null);
}
public static UsernamePasswordAuthentication authenticated(User user) {
return new UsernamePasswordAuthentication(user.getUsername(), null, true, user);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override
public String getCredentials() {
return password;
}
@Override
public User getDetails() {
return user;
}
@Override
public String getPrincipal() {
return username;
}
@Override
public boolean isAuthenticated() {
return authenticated;
}
@Override
public void setAuthenticated(boolean authenticated) throws IllegalArgumentException {
this.authenticated = authenticated;
}
@Override
public String getName() {
return username;
}
@Override
public void eraseCredentials() {
this.password = null;
}
public void setDetails(User user) {
this.user = user;
}
}
@@ -0,0 +1,83 @@
package com.onixbyte.deltaforceguide.security.provider;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.exeption.BizException;
import com.onixbyte.deltaforceguide.manager.UserManager;
import com.onixbyte.deltaforceguide.repository.UserCredentialRepository;
import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication;
import com.onixbyte.deltaforceguide.shared.CredentialProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
private static final Logger log = LoggerFactory.getLogger(UsernamePasswordAuthenticationProvider.class);
private final UserManager userManager;
private final PasswordEncoder passwordEncoder;
private final UserCredentialRepository userCredentialRepository;
@Autowired
public UsernamePasswordAuthenticationProvider(
UserManager userManager,
PasswordEncoder passwordEncoder,
UserCredentialRepository userCredentialRepository
) {
this.userManager = userManager;
this.passwordEncoder = passwordEncoder;
this.userCredentialRepository = userCredentialRepository;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) {
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "用户认证失败,请稍后再试。");
}
// get userContainer from database
var userContainer = userManager.findByUsernameOrEmail(usernamePasswordAuthentication.getPrincipal());
if (userContainer.isEmpty()) {
log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal());
throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。");
}
var user = userContainer.get();
var userCredentialExample = new UserCredential();
userCredentialExample.setUserId(user.getId());
userCredentialExample.setProvider(CredentialProvider.LOCAL);
// get userContainer credentials from database
var userCredentials = userCredentialRepository.findOne(Example.of(userCredentialExample))
.orElseThrow(() -> new BizException(HttpStatus.UNAUTHORIZED, "您还没有配置密码,请联系管理员处理。"));
// validate password
if (!passwordEncoder.matches(usernamePasswordAuthentication.getCredentials(), userCredentials.getCredential())) {
log.error("User {} is trying to authenticate but password is incorrect.", usernamePasswordAuthentication.getPrincipal());
throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。");
}
// erase credentials
usernamePasswordAuthentication.eraseCredentials();
// set values
usernamePasswordAuthentication.setAuthenticated(true);
usernamePasswordAuthentication.setDetails(user);
return usernamePasswordAuthentication;
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthentication.class.isAssignableFrom(authentication);
}
}
@@ -0,0 +1,40 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.exeption.BizException;
import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final AuthenticationManager authenticationManager;
public AuthService(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
public User login(LoginRequest request) {
var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication
.unauthenticated(request.principle(), request.credential()));
if (!(_authentication instanceof UsernamePasswordAuthentication authentication)) {
log.error(
"Type mismatched, required type is UsernamePasswordAuthentication but got {}.",
_authentication.getClass()
);
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "登录服务异常,请稍后再试。");
}
return authentication.getDetails();
}
}
@@ -0,0 +1,47 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.manager.CookieManager;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class CookieService {
private final CookieManager cookieManager;
public CookieService(CookieManager cookieManager) {
this.cookieManager = cookieManager;
}
public ResponseCookie buildCookie(String cookieName, String value) {
return buildCookieInternal(cookieName, value, cookieManager.getMaxAge());
}
public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) {
return buildCookieInternal(cookieName, value, validDuration);
}
/**
* Creates a response cookie builder with specified name, value and valid duration.
*
* @param name name of the cookie
* @param value value of the cookie
* @param maxAge valid duration of the cookie
* @return cookie builder
*/
protected ResponseCookie buildCookieInternal(
String name,
String value,
Duration maxAge
) {
return ResponseCookie.from(name, value)
.secure(cookieManager.getSecure())
.maxAge(maxAge)
.httpOnly(cookieManager.getHttpOnly())
.path(cookieManager.getPath())
.sameSite(cookieManager.getSameSite().attributeValue())
.build();
}
}
@@ -0,0 +1,19 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.deltaforceguide.manager.DailyPasswordManager;
import org.springframework.stereotype.Service;
@Service
public class DailyPasswordService {
private final DailyPasswordManager dailyPasswordManager;
public DailyPasswordService(DailyPasswordManager dailyPasswordManager) {
this.dailyPasswordManager = dailyPasswordManager;
}
public DailyPasswordResponse getDailyPassword() {
return dailyPasswordManager.getDailyPassword();
}
}
@@ -1,9 +1,11 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.domain.dto.FirearmRequest;
import com.onixbyte.deltaforceguide.domain.dto.FirearmResponse;
import com.onixbyte.deltaforceguide.domain.dto.PageResponse;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import com.onixbyte.deltaforceguide.exeption.BizException;
import com.onixbyte.deltaforceguide.repository.FirearmRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -36,5 +38,43 @@ public class FirearmService {
.map(FirearmResponse::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
}
public FirearmResponse addFirearm(FirearmRequest request) {
var firearm = firearmRepository.save(Firearm.builder()
.name(request.name())
.type(request.type())
.level(request.level())
.calibre(request.calibre())
.fireRate(request.fireRate())
.armourDamage(request.armourDamage())
.bodyDamage(request.bodyDamage())
.review(request.review())
.build());
return FirearmResponse.from(firearm);
}
@Transactional
public FirearmResponse updateFirearm(Long id, FirearmRequest request) {
var firearm = firearmRepository.findById(id)
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
firearm.setName(request.name());
firearm.setType(request.type());
firearm.setLevel(request.level());
firearm.setCalibre(request.calibre());
firearm.setFireRate(request.fireRate());
firearm.setArmourDamage(request.armourDamage());
firearm.setBodyDamage(request.bodyDamage());
firearm.setReview(request.review());
return FirearmResponse.from(firearmRepository.save(firearm));
}
@Transactional
public void deleteFirearm(Long id) {
Firearm firearm = firearmRepository.findById(id)
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
firearmRepository.delete(firearm);
}
}
@@ -1,9 +1,18 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse;
import com.onixbyte.deltaforceguide.domain.dto.PageResponse;
import com.onixbyte.deltaforceguide.domain.dto.TuningRequest;
import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.domain.entity.Modification;
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
import com.onixbyte.deltaforceguide.repository.FirearmRepository;
import com.onixbyte.deltaforceguide.repository.ModificationRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
@@ -11,20 +20,47 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class ModificationService {
private final ModificationRepository modificationRepository;
private final FirearmRepository firearmRepository;
private final ObjectMapper objectMapper;
public ModificationService(ModificationRepository modificationRepository) {
public ModificationService(
ModificationRepository modificationRepository,
FirearmRepository firearmRepository,
ObjectMapper objectMapper
) {
this.modificationRepository = modificationRepository;
this.firearmRepository = firearmRepository;
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
public PageResponse<ModificationResponse> pageQuery(Long firearmId, Pageable pageable) {
Page<Modification> page = firearmId == null
? modificationRepository.findAllBy(pageable)
: modificationRepository.findAllByFirearm_Id(firearmId, pageable);
public PageResponse<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) {
String tagsJson = null;
if (tags != null && !tags.isEmpty()) {
try {
tagsJson = objectMapper.writeValueAsString(tags);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize tags", e);
}
}
Page<Modification> page;
if (tagsJson != null || firearmId != null) {
page = modificationRepository.pageQueryByFirearmAndTags(firearmId, tagsJson, pageable);
} else {
page = modificationRepository.findAllBy(pageable);
}
return PageResponse.from(page.map(ModificationResponse::from));
}
@@ -35,5 +71,139 @@ public class ModificationService {
.map(ModificationResponse::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
}
@Transactional(readOnly = true)
public List<String> findAllTags(Long firearmId) {
return modificationRepository.findAllTags(firearmId);
}
@Transactional
public ModificationResponse create(ModificationRequest request) {
Firearm firearm = firearmRepository.findById(request.firearmId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId()));
Modification modification = toEntity(request, firearm);
return ModificationResponse.from(modificationRepository.save(modification));
}
@Transactional
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
Set<Long> firearmIds = requests.stream()
.map(ModificationRequest::firearmId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Map<Long, Firearm> firearmMap = new HashMap<>();
firearmRepository.findAllById(firearmIds).forEach(firearm -> firearmMap.put(firearm.getId(), firearm));
if (firearmMap.size() != firearmIds.size()) {
List<Long> missingFirearmIds = firearmIds.stream()
.filter(id -> !firearmMap.containsKey(id))
.toList();
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + missingFirearmIds);
}
List<Modification> modifications = requests.stream()
.map(request -> toEntity(request, firearmMap.get(request.firearmId())))
.toList();
return modificationRepository.saveAll(modifications)
.stream()
.map(ModificationResponse::from)
.toList();
}
@Transactional
public ModificationResponse update(Long id, ModificationRequest request) {
Modification modification = modificationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
Firearm firearm = firearmRepository.findById(request.firearmId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId()));
modification.setFirearm(firearm);
modification.setName(request.name());
modification.setCode(request.code());
modification.setTags(safeTags(request.tags()));
modification.setAccessories(toAccessories(request.accessories()));
modification.setNote(request.note());
modification.setAuthor(request.author());
modification.setVideoUrl(request.videoUrl());
return ModificationResponse.from(modificationRepository.save(modification));
}
@Transactional
public void delete(Long id) {
Modification modification = modificationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
modificationRepository.delete(modification);
}
@Transactional
public void batchDelete(List<Long> ids) {
Set<Long> uniqueIds = new LinkedHashSet<>(ids);
List<Modification> modifications = modificationRepository.findAllById(uniqueIds);
if (modifications.size() != uniqueIds.size()) {
Set<Long> foundIds = modifications.stream()
.map(Modification::getId)
.collect(java.util.stream.Collectors.toSet());
List<Long> missingIds = uniqueIds.stream()
.filter(id -> !foundIds.contains(id))
.toList();
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + missingIds);
}
modificationRepository.deleteAllInBatch(modifications);
}
private Modification toEntity(ModificationRequest request, Firearm firearm) {
return Modification.builder()
.firearm(firearm)
.name(request.name())
.code(request.code())
.tags(safeTags(request.tags()))
.accessories(toAccessories(request.accessories()))
.note(request.note())
.author(request.author())
.videoUrl(request.videoUrl())
.build();
}
private List<String> safeTags(List<String> tags) {
return tags == null ? new ArrayList<>() : tags;
}
private List<Accessory> toAccessories(List<AccessoryRequest> accessoryRequests) {
if (accessoryRequests == null) {
return new ArrayList<>();
}
return accessoryRequests.stream()
.map(this::toAccessory)
.toList();
}
private Accessory toAccessory(AccessoryRequest request) {
Accessory accessory = new Accessory();
accessory.setSlotName(request.slotName());
accessory.setAccessoryName(request.accessoryName());
accessory.setTunings(toTunings(request.tunings()));
return accessory;
}
private List<Tuning> toTunings(List<TuningRequest> tuningRequests) {
if (tuningRequests == null) {
return new ArrayList<>();
}
return tuningRequests.stream()
.map(this::toTuning)
.toList();
}
private Tuning toTuning(TuningRequest request) {
Tuning tuning = new Tuning();
tuning.setTuningName(request.tuningName());
tuning.setTuningValue(request.tuningValue());
return tuning;
}
}
@@ -0,0 +1,100 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.manager.UserCredentialManager;
import com.onixbyte.deltaforceguide.manager.UserManager;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Service
public class UserService {
private final UserManager userManager;
private final UserCredentialManager userCredentialManager;
public UserService(UserManager userManager, UserCredentialManager userCredentialManager) {
this.userManager = userManager;
this.userCredentialManager = userCredentialManager;
}
@Transactional(readOnly = true)
public List<User> findAll() {
return userManager.findAll();
}
@Transactional(readOnly = true)
public User queryById(Long id) {
return userManager.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id));
}
@Transactional(readOnly = true)
public User queryByUsername(String username) {
return userManager.findByUsername(username)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + username));
}
@Transactional
public User create(User user) {
return userManager.save(user);
}
@Transactional
public User update(User user) {
if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId());
}
return userManager.save(user);
}
@Transactional(readOnly = true)
public List<UserCredential> findCredentials(Long userId) {
ensureUserExists(userId);
return userCredentialManager.findAllByUserId(userId);
}
@Transactional(readOnly = true)
public UserCredential queryCredential(Long userId, String provider) {
ensureUserExists(userId);
return userCredentialManager.findByUserIdAndProvider(userId, provider)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"User credential not found: userId=" + userId + ", provider=" + provider));
}
@Transactional
public UserCredential upsertCredential(Long userId, String provider, String credential) {
User user = ensureUserExists(userId);
UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider)
.orElseGet(UserCredential::new);
userCredential.setUser(user);
userCredential.setProvider(provider);
userCredential.setCredential(credential);
return userCredentialManager.save(userCredential);
}
@Transactional
public void deleteCredential(Long userId, String provider) {
ensureUserExists(userId);
userCredentialManager.deleteByUserIdAndProvider(userId, provider);
}
@Transactional
public void deleteById(Long id) {
ensureUserExists(id);
userCredentialManager.deleteAllByUserId(id);
userManager.deleteById(id);
}
private User ensureUserExists(Long userId) {
return userManager.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + userId));
}
}
@@ -0,0 +1,6 @@
package com.onixbyte.deltaforceguide.shared;
public class CookieName {
public static final String ACCESS_TOKEN = "AccessToken";
}
@@ -0,0 +1,6 @@
package com.onixbyte.deltaforceguide.shared;
public class CredentialProvider {
public static final String LOCAL = "LOCAL";
}
@@ -0,0 +1,13 @@
package com.onixbyte.deltaforceguide.utils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
public class DateTimeUtil {
public static Instant asInstant(LocalDateTime ldt) {
return ldt.atZone(ZoneId.systemDefault())
.toInstant();
}
}
-1
View File
@@ -43,4 +43,3 @@ logging:
level:
org.hibernate:
orm.connections.pooling: off
@@ -0,0 +1,61 @@
-- 创建新表
DROP TABLE IF EXISTS firearm_new;
CREATE TABLE firearm_new
(
id BIGSERIAL NOT NULL,
name VARCHAR(64) NOT NULL,
type INTEGER NOT NULL,
level VARCHAR(10) NOT NULL,
calibre VARCHAR(20) NOT NULL,
fire_rate INTEGER NOT NULL,
armour_damage INTEGER NOT NULL,
body_damage INTEGER NOT NULL,
review TEXT NULL,
CONSTRAINT firearm_new_pkey PRIMARY KEY (id)
);
-- 迁移数据
INSERT INTO firearm_new(id, name, type, level, calibre, fire_rate, armour_damage, body_damage,
review)
SELECT id,
name,
type,
level,
'',
0,
0,
0,
review
FROM firearm;
-- 处理外键(关键步骤)
-- 先删除指向旧表的外键约束
ALTER TABLE modification
DROP CONSTRAINT fk_modification_firearm;
-- 重命名旧表和索引
ALTER TABLE firearm
RENAME TO firearm_legacy;
ALTER INDEX firearm_pkey RENAME TO firearm_legacy_pkey;
-- 重命名新表和索引
ALTER TABLE firearm_new
RENAME TO firearm;
ALTER INDEX firearm_new_pkey RENAME TO firearm_pkey;
-- 重新建立外键,指向新的 firearm 表
ALTER TABLE modification
ADD CONSTRAINT fk_modification_firearm
FOREIGN KEY (firearm_id) REFERENCES firearm (id);
-- 序列所有权与名称修正
ALTER SEQUENCE firearm_id_seq RENAME TO firearm_legacy_id_seq;
ALTER SEQUENCE firearm_new_id_seq RENAME TO firearm_id_seq;
ALTER SEQUENCE firearm_id_seq OWNED BY firearm.id;
-- 更新序列计数器
SELECT setval('firearm_id_seq', coalesce(max(id), 1))
FROM firearm;
-- 删除旧表
DROP TABLE IF EXISTS firearm_legacy CASCADE;
@@ -0,0 +1,18 @@
DROP TABLE IF EXISTS app_user CASCADE;
CREATE TABLE app_user
(
id BIGSERIAL NOT NULL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
CONSTRAINT app_user_username_key UNIQUE (username),
CONSTRAINT app_user_email_key UNIQUE (email)
);
DROP TABLE IF EXISTS app_user_credential CASCADE;
CREATE TABLE app_user_credential
(
user_id BIGINT NOT NULL REFERENCES app_user (id),
provider VARCHAR(255) NOT NULL,
credential VARCHAR(255) NOT NULL,
CONSTRAINT app_user_credential_pkey PRIMARY KEY (user_id, provider)
);
@@ -0,0 +1,2 @@
ALTER TABLE modification
ADD accessories JSONB NOT NULL DEFAULT '[]';