Compare commits
45 Commits
1.3.1
..
4e2da0debc
| Author | SHA1 | Date | |
|---|---|---|---|
|
4e2da0debc
|
|||
|
0815d1d618
|
|||
|
d323e4f8f7
|
|||
|
eb2d9b3369
|
|||
| a0d54cc12d | |||
| 4eafb3ade7 | |||
|
9594efe716
|
|||
|
5b5062aae9
|
|||
|
b0c41e08ea
|
|||
|
ed2a0f4ae0
|
|||
|
de61e1feb7
|
|||
|
3616ad9eab
|
|||
|
20bc18d416
|
|||
|
4ee741d307
|
|||
|
0530c1f633
|
|||
|
8a9cf110af
|
|||
|
c30b5701e4
|
|||
|
7fafa0d903
|
|||
|
8c8ca58b74
|
|||
|
12469f1b27
|
|||
|
44271eeec4
|
|||
|
f9c210c8b3
|
|||
|
ce330bca87
|
|||
|
bd4fe65b03
|
|||
|
eb22b3c4bb
|
|||
|
7032343487
|
|||
|
243283b788
|
|||
|
4810ef2b1f
|
|||
|
72ec875802
|
|||
|
6240ec1016
|
|||
|
8d24b6082d
|
|||
|
9bc70d5370
|
|||
|
d44f5f74fe
|
|||
|
f866d93fb4
|
|||
|
66b37ec20d
|
|||
|
0d70b27653
|
|||
|
673ba03f2b
|
|||
| f6255d396c | |||
|
26cea1db82
|
|||
|
49f9b59b99
|
|||
|
1f42921689
|
|||
| 8f102f54c7 | |||
|
9fe292963c
|
|||
| d3681916b2 | |||
|
d27f6455d8
|
@@ -155,3 +155,8 @@ gradle-app.setting
|
|||||||
.project
|
.project
|
||||||
# JDT-specific (Eclipse Java Development Tools)
|
# JDT-specific (Eclipse Java Development Tools)
|
||||||
.classpath
|
.classpath
|
||||||
|
|
||||||
|
gradle.properties
|
||||||
|
|
||||||
|
# frp config
|
||||||
|
frpc.toml
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ release:
|
|||||||
- JAR_FILE=$(find build/libs -name '*.jar' | head -1)
|
- JAR_FILE=$(find build/libs -name '*.jar' | head -1)
|
||||||
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
|
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
|
||||||
- docker build
|
- docker build
|
||||||
--provenance=false
|
|
||||||
-f Dockerfile.ci
|
-f Dockerfile.ci
|
||||||
--build-arg JAR_FILE="$JAR_FILE"
|
--build-arg JAR_FILE="$JAR_FILE"
|
||||||
-t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
|
-t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT Licence
|
||||||
|
|
||||||
|
Copyright (c) 2026 OnixByte
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Delta Force Guide Server
|
||||||
|
|
||||||
|
REST API backend for managing **Delta Force** game firearm builds and modifications. Provides endpoints to browse, create, and share weapon configurations including attachments, tuning setups, tags, and build reviews.
|
||||||
|
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-----------|------------------------------------------------------------|
|
||||||
|
| Language | Java 21 (Amazon Corretto) |
|
||||||
|
| Framework | Spring Boot 3.x (Web, Security, Data JPA, Cache, Actuator) |
|
||||||
|
| Build | Gradle (Kotlin DSL) |
|
||||||
|
| Database | PostgreSQL (Flyway migrations, JSONB columns) |
|
||||||
|
| Cache | Redis (2-hour TTL) |
|
||||||
|
| Auth | Custom JWT via httpOnly cookies + BCrypt |
|
||||||
|
| API Docs | springdoc-openapi (Swagger UI, dev profile only) |
|
||||||
|
| Container | Multi-stage Docker image (Amazon Corretto 21 → Alpine) |
|
||||||
|
| CI/CD | GitHub Actions — builds on release publish |
|
||||||
|
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- JDK 21 (Amazon Corretto recommended)
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis
|
||||||
|
|
||||||
|
### Configure
|
||||||
|
|
||||||
|
Copy and customise the development config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config/application-prod.yaml.example \
|
||||||
|
config/application-dev.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in your datasource and Redis connection details, then place the production config at `config/`, then enable it by environment variable `SPRING_PROFILES_ACTIVE`.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compile (skip tests)
|
||||||
|
./gradlew build -x test
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# Build executable JAR
|
||||||
|
./gradlew bootJar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SPRING_PROFILES_ACTIVE=prod java -jar build/libs/delta-force-guide-server-$version.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
Swagger UI is available at `http://localhost:8080/swagger-ui.html` when the `dev` profile is active.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
- **Firearm** — weapon base stats (name, type, level, calibre, fire rate, armour/body damage, review)
|
||||||
|
- **Modification** — a build attached to a firearm, with name, code, tags (JSONB), accessories including nested tuning objects (JSONB), author notes, and video links
|
||||||
|
- **App User** — registered user (username, email) with hashed credentials via BCrypt
|
||||||
|
|
||||||
|
Tags and accessories are stored as PostgreSQL JSONB columns using [Hypersistence Utils](https://github.com/vladmihalcea/hypersistence-utils), enabling flexible per-build metadata and filtering with the `@>` operator.
|
||||||
|
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Controller → Service → Manager → Repository / Mapper
|
||||||
|
(HTTP) (logic) (@Transactional) (data access)
|
||||||
|
```
|
||||||
|
|
||||||
|
The call chain is strictly enforced — skipping layers is not permitted. All request/response objects are Java records with static `from()` factory methods for entity-to-DTO conversion.
|
||||||
|
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker pull registry.onixbyte.cn/onixbyte/delta-force-guide-server:latest
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
docker run -p 8080:8080 \
|
||||||
|
-v /path/to/config:/app/config \
|
||||||
|
-e SPRING_PROFILES_ACTIVE=$your_active_profiles
|
||||||
|
delta-force-guide-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-built images are published to **Self-hosted GitLab Container Registry** (`registry.onixbyte.cn/onixbyte/delta-force-guide-server`) on every release.
|
||||||
|
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
GitLab CI triggers on **tags**. The pipeline:
|
||||||
|
|
||||||
|
1. Builds the boot JAR with the release tag as the version
|
||||||
|
2. Uploads the JAR as a release asset
|
||||||
|
3. Builds and pushes a multi-arch Docker image to GHCR tagged with both `:latest` and `:<version>`
|
||||||
|
|
||||||
|
No tests are run in CI by design — tests are expected to pass locally before a release is cut.
|
||||||
|
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
| Profile | Purpose |
|
||||||
|
|---------|-----------------------------------------------------------------------|
|
||||||
|
| `dev` | Enables Swagger UI, connects to dev DB/Redis at `dfguide.onixbyte.cn` |
|
||||||
|
| default | Production mode, no Swagger, uses production datasource |
|
||||||
|
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/com/onixbyte/deltaforceguide/
|
||||||
|
├── client/ External service HTTP clients
|
||||||
|
├── config/ Spring bean definitions (Security, CORS, Cache, Jackson, MyBatis)
|
||||||
|
├── controller/ REST controllers
|
||||||
|
├── domain/
|
||||||
|
│ ├── converter/ JPA attribute converters
|
||||||
|
│ ├── dto/ Request/response records
|
||||||
|
│ └── entity/ JPA entities
|
||||||
|
├── enumeration/ Enums (FirearmType)
|
||||||
|
├── exception/ Custom BizException with HTTP status mapping
|
||||||
|
├── filter/ TokenAuthenticationFilter (JWT through OncePerRequestFilter)
|
||||||
|
├── manager/ @Transactional wrappers around repositories
|
||||||
|
├── mapper/ MyBatis mapper interfaces (reserved for future use)
|
||||||
|
├── properties/ @ConfigurationProperties records
|
||||||
|
├── repository/ Spring Data JPA repositories
|
||||||
|
├── security/
|
||||||
|
│ ├── authentication/ Custom UsernamePasswordAuthentication
|
||||||
|
│ └── provider/ UsernamePasswordAuthenticationProvider
|
||||||
|
├── service/ Business logic
|
||||||
|
├── shared/ Constants and utility classes
|
||||||
|
└── utils/ General-purpose helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
The application exposes its version at `/versions` (GET).
|
||||||
|
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
This project is licensed under the [MIT Licence](LICENCE). Copyright © 2026 OnixByte.
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ dependencies {
|
|||||||
implementation(libs.onixbyte.identityGenerator)
|
implementation(libs.onixbyte.identityGenerator)
|
||||||
implementation(libs.onixbyte.captcha)
|
implementation(libs.onixbyte.captcha)
|
||||||
implementation(libs.onixbyte.regions)
|
implementation(libs.onixbyte.regions)
|
||||||
|
implementation(libs.onixbyte.cryptoToolbox)
|
||||||
implementation(libs.jwt.core)
|
implementation(libs.jwt.core)
|
||||||
implementation(libs.spring.boot.configurationProcessor)
|
implementation(libs.spring.boot.configurationProcessor)
|
||||||
implementation(libs.spring.boot.actuator)
|
implementation(libs.spring.boot.actuator)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://localhost:5432/dfguide_dev
|
||||||
|
username: postgres
|
||||||
|
password: 123456
|
||||||
|
driver-class-name: org.postgresql.Driver
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
database: 0
|
||||||
|
# password: 6hLFVqfGPviTYukn # Uncomment if password is necessary
|
||||||
|
|
||||||
|
logging:
|
||||||
|
pattern:
|
||||||
|
# dateformat: dd MMM yyyy HH:mm:ss.SSS # Modify this for custom date format.
|
||||||
|
|
||||||
|
app:
|
||||||
|
common:
|
||||||
|
version: 1.3.0.8-dev # Application version, you can change to any version you like, used for communication with frontend.
|
||||||
|
cors:
|
||||||
|
allowed-origins: # Cross-origin allowed origins
|
||||||
|
- "http://localhost:5173" # Dev server for vite.
|
||||||
|
- "http://localhost:4173" # Preview server for vite.
|
||||||
|
allow-credentials: true # Must be set to `true` since we are using cookie. You can change it only when you have modified how this application executing authentication.
|
||||||
|
allow-private-network: false
|
||||||
|
allowed-headers:
|
||||||
|
- "Content-Type"
|
||||||
|
- "Authorization"
|
||||||
|
allowed-methods:
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- PUT
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
exposed-headers:
|
||||||
|
- "Content-Type"
|
||||||
|
- "Authorization"
|
||||||
|
max-age: PT2H
|
||||||
|
jwt:
|
||||||
|
issuer: dfguide.local # Issuer host
|
||||||
|
secret: qwertyuiopasdfghjklzxcvbnm123456 # JWT singing secret, a 32-byte long or longer string is recommended
|
||||||
|
valid-time: PT2H # JWT valid duration
|
||||||
|
cookie: # Cookie settings.
|
||||||
|
http-only: true
|
||||||
|
secure: false
|
||||||
|
same-site: lax
|
||||||
|
path: '/'
|
||||||
|
max-age: PT2H
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: true # Set to `false` if you do not need api docs (recommended in production mode).
|
||||||
|
swagger-ui:
|
||||||
|
enabled: true # Set to `false` if you do not need swagger ui (recommended in production mode).
|
||||||
@@ -1 +0,0 @@
|
|||||||
artefactVersion = 1.2.0
|
|
||||||
@@ -4,10 +4,9 @@ javaJwtVersion = "4.5.1"
|
|||||||
postgresDriverVersion = "42.7.9"
|
postgresDriverVersion = "42.7.9"
|
||||||
h2Version = "2.2.224"
|
h2Version = "2.2.224"
|
||||||
springBootVersion = "3.5.13"
|
springBootVersion = "3.5.13"
|
||||||
springSecurityVersion = "6.5.2"
|
|
||||||
reactorVersion = "3.7.8"
|
reactorVersion = "3.7.8"
|
||||||
junitPlatformVersion = "1.12.2"
|
junitPlatformVersion = "1.12.2"
|
||||||
onixbyteVersion = "3.3.0"
|
onixbyteVersion = "3.4.0"
|
||||||
onixbyteCaptcha = "1.1.0"
|
onixbyteCaptcha = "1.1.0"
|
||||||
onixbyteRegions = "2025.12.0"
|
onixbyteRegions = "2025.12.0"
|
||||||
awsSdkVersion = "2.25.48"
|
awsSdkVersion = "2.25.48"
|
||||||
@@ -32,6 +31,7 @@ jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-dat
|
|||||||
onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" }
|
onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" }
|
||||||
onixbyte-tuple = { group = "com.onixbyte", name = "tuple" }
|
onixbyte-tuple = { group = "com.onixbyte", name = "tuple" }
|
||||||
onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" }
|
onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" }
|
||||||
|
onixbyte-cryptoToolbox = { group = "com.onixbyte", name = "crypto-toolbox", version.ref = "onixbyteVersion" }
|
||||||
onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" }
|
onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" }
|
||||||
onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" }
|
onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" }
|
||||||
onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" }
|
onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" }
|
||||||
@@ -42,12 +42,10 @@ mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spri
|
|||||||
spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }
|
spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }
|
||||||
hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" }
|
hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" }
|
||||||
postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" }
|
postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" }
|
||||||
mysql-driver = { group = "com.mysql", name = "mysql-connector-j" }
|
|
||||||
h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" }
|
h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" }
|
||||||
spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" }
|
spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" }
|
||||||
flyway-core = { group = "org.flywaydb", name = "flyway-core" }
|
flyway-core = { group = "org.flywaydb", name = "flyway-core" }
|
||||||
flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" }
|
flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" }
|
||||||
flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" }
|
|
||||||
|
|
||||||
# Spring Boot Core & Web
|
# Spring Boot Core & Web
|
||||||
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
|
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for the Delta Force Guide Server application.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class DeltaForceGuideApplication {
|
public class DeltaForceGuideApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for generating and verifying JSON Web Tokens using the Auth0 java-jwt library.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TokenClient {
|
public class TokenClient {
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
package com.onixbyte.deltaforceguide.config;
|
package com.onixbyte.deltaforceguide.config;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor;
|
||||||
|
import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor;
|
||||||
import com.onixbyte.deltaforceguide.properties.AppProperties;
|
import com.onixbyte.deltaforceguide.properties.AppProperties;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(AppProperties.class)
|
@EnableConfigurationProperties(AppProperties.class)
|
||||||
public class AppConfig {
|
public class AppConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final TrafficInterceptor trafficInterceptor;
|
||||||
|
|
||||||
|
private final GitHubWebhookInterceptor gitHubWebhookInterceptor;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public AppConfig(
|
||||||
|
TrafficInterceptor trafficInterceptor,
|
||||||
|
GitHubWebhookInterceptor gitHubWebhookInterceptor
|
||||||
|
) {
|
||||||
|
this.trafficInterceptor = trafficInterceptor;
|
||||||
|
this.gitHubWebhookInterceptor = gitHubWebhookInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(trafficInterceptor);
|
||||||
|
registry.addInterceptor(gitHubWebhookInterceptor)
|
||||||
|
.addPathPatterns("/webhooks/github");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,21 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for CORS (Cross-Origin Resource Sharing) policies.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties({CorsProperties.class})
|
@EnableConfigurationProperties({CorsProperties.class})
|
||||||
public class CorsConfig {
|
public class CorsConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the CORS configuration source with properties from configuration.
|
||||||
|
*
|
||||||
|
* @param properties the CORS configuration properties
|
||||||
|
* @return the CORS configuration source
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource(
|
public CorsConfigurationSource corsConfigurationSource(
|
||||||
CorsProperties properties
|
CorsProperties properties
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.config;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.filter.WebhookFilter;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class FilterConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<WebhookFilter> webhookFilterBean(WebhookFilter webhookFilter) {
|
||||||
|
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
|
||||||
|
|
||||||
|
registrationBean.setFilter(webhookFilter);
|
||||||
|
registrationBean.addUrlPatterns("/webhooks/*");
|
||||||
|
registrationBean.setOrder(1);
|
||||||
|
|
||||||
|
return registrationBean;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilde
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for Jackson JSON serialisation and deserialisation settings.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class JacksonConfig {
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config;
|
|||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for MyBatis SQL mapping framework integration.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"})
|
@MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"})
|
||||||
public class MyBatisConfig {
|
public class MyBatisConfig {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||||
|
import io.swagger.v3.oas.annotations.info.Contact;
|
||||||
|
import io.swagger.v3.oas.annotations.info.Info;
|
||||||
|
import io.swagger.v3.oas.annotations.info.License;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@OpenAPIDefinition(
|
||||||
|
info = @Info(
|
||||||
|
title = "Delta Force Guide Server",
|
||||||
|
description = "API for managing Delta Force game firearm builds",
|
||||||
|
version = "1.3.4",
|
||||||
|
contact = @Contact(
|
||||||
|
name = "Zihlu Wang",
|
||||||
|
email = "zihlu.wang@onixbyte.com"
|
||||||
|
),
|
||||||
|
license = @License(
|
||||||
|
name = "MIT",
|
||||||
|
url = "https://git.onixbyte.cn/onixbyte/delta-force-guide-server/-/raw/main/LICENCE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfiguration {
|
||||||
|
}
|
||||||
@@ -23,12 +23,23 @@ import org.springframework.security.web.SecurityFilterChain;
|
|||||||
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security configuration defining authentication, authorisation, and filter chains.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
|
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the HTTP security filter chain including endpoint authorisation and JWT filter.
|
||||||
|
*
|
||||||
|
* @param http the HTTP security builder
|
||||||
|
* @return the configured security filter chain
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(
|
public SecurityFilterChain securityFilterChain(
|
||||||
HttpSecurity httpSecurity,
|
HttpSecurity httpSecurity,
|
||||||
@@ -48,11 +59,20 @@ public class SecurityConfig {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the BCrypt password encoder for credential hashing.
|
||||||
|
* @return the BCrypt password encoder
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the authentication manager for the security configuration.
|
||||||
|
*
|
||||||
|
* @return the authentication manager
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public AuthenticationManager authenticationManager(
|
public AuthenticationManager authenticationManager(
|
||||||
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
|
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for Spring Data JPA auditing and repository settings.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"})
|
@EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"})
|
||||||
public class SpringDataConfig {
|
public class SpringDataConfig {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.config;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(WebhookProperties.class)
|
||||||
|
public class WebhookConfig {
|
||||||
|
}
|
||||||
@@ -18,7 +18,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for user authentication endpoints (login, logout).
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
|
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/auth")
|
@RequestMapping("/auth")
|
||||||
@@ -38,12 +44,14 @@ public class AuthController {
|
|||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
|
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
|
||||||
var user = authService.login(request);
|
var user = authService.login(request);
|
||||||
|
var currentTime = LocalDateTime.now();
|
||||||
var accessToken = tokenClient.generateToken(user);
|
var accessToken = tokenClient.generateToken(user);
|
||||||
var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken);
|
var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken);
|
||||||
|
var cookieMaxAge = accessTokenCookie.getMaxAge();
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
|
.header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
|
||||||
.body(UserResponse.from(user));
|
.body(UserResponse.from(user, currentTime.plus(cookieMaxAge)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresAuth
|
@RequiresAuth
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for retrieving daily-generated passwords.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Tag(name = "每日密码", description = "获取每日密码信息")
|
@Tag(name = "每日密码", description = "获取每日密码信息")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/daily-passwords")
|
@RequestMapping("/daily-passwords")
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import org.springframework.data.domain.Sort;
|
|||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for firearm CRUD operations.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Tag(name = "武器管理", description = "与武器有关的操作")
|
@Tag(name = "武器管理", description = "与武器有关的操作")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/firearms")
|
@RequestMapping("/firearms")
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.controller;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest;
|
||||||
|
import com.onixbyte.deltaforceguide.service.WebhookService;
|
||||||
|
import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/webhooks/github")
|
||||||
|
public class GitHubWebhookController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GitHubWebhookController.class);
|
||||||
|
|
||||||
|
private final WebhookService webhookService;
|
||||||
|
|
||||||
|
public GitHubWebhookController(WebhookService webhookService) {
|
||||||
|
this.webhookService = webhookService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Void> handleWebhook(
|
||||||
|
@RequestHeader(GitHubWebhookHeader.EVENT) String event,
|
||||||
|
@RequestBody GitHubIssueRequest request
|
||||||
|
) {
|
||||||
|
if (!"issues".equals(event)) {
|
||||||
|
log.debug("Ignoring non-issue event: {}", event);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
if (!"opened".equals(request.action())) {
|
||||||
|
log.debug("Ignoring issue action: {}", request.action());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
webhookService.processIssueEvent(request);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global exception handler that translates exceptions into standard error responses.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
public class GlobalExceptionHandler {
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for modification CRUD operations, including batch creation and deletion.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Tag(name = "改装管理", description = "对枪械改装的管理")
|
@Tag(name = "改装管理", description = "对枪械改装的管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/modifications")
|
@RequestMapping("/modifications")
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import com.onixbyte.deltaforceguide.service.ModificationService;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for retrieving available modification tags.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Tag(name = "标签管理", description = "管理标签信息")
|
@Tag(name = "标签管理", description = "管理标签信息")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/tags")
|
@RequestMapping("/tags")
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package com.onixbyte.deltaforceguide.controller;
|
|||||||
|
|
||||||
import com.onixbyte.deltaforceguide.service.AppService;
|
import com.onixbyte.deltaforceguide.service.AppService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Tag(name = "版本信息")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/versions")
|
@RequestMapping("/versions")
|
||||||
public class VersionController {
|
public class VersionController {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType;
|
|||||||
import jakarta.persistence.AttributeConverter;
|
import jakarta.persistence.AttributeConverter;
|
||||||
import jakarta.persistence.Converter;
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA attribute converter that maps {@link FirearmType} enum to/from its integer database representation.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Converter
|
@Converter
|
||||||
public class FirearmTypeConverter implements AttributeConverter<FirearmType, Integer> {
|
public class FirearmTypeConverter implements AttributeConverter<FirearmType, Integer> {
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating or updating an accessory attached to a modification.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record AccessoryRequest(
|
public record AccessoryRequest(
|
||||||
@NotBlank(message = "插槽名称不能为空")
|
@NotBlank(message = "插槽名称不能为空")
|
||||||
String slotName,
|
String slotName,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for an accessory attached to a modification.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record AccessoryResponse(
|
public record AccessoryResponse(
|
||||||
String slotName,
|
String slotName,
|
||||||
String accessoryName,
|
String accessoryName,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.onixbyte.deltaforceguide.domain.dto;
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing a single daily-generated password for a map.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record DailyPassword(
|
public record DailyPassword(
|
||||||
String mapName,
|
String mapName,
|
||||||
String password
|
String password
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO containing daily password data including update information and password list.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record DailyPasswordData(
|
public record DailyPasswordData(
|
||||||
String updateDate,
|
String updateDate,
|
||||||
Integer totalCount,
|
Integer totalCount,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.onixbyte.deltaforceguide.domain.dto;
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO holding metadata about the daily password source and update tracking.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record DailyPasswordMetadata(
|
public record DailyPasswordMetadata(
|
||||||
String version,
|
String version,
|
||||||
String author
|
String author
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.onixbyte.deltaforceguide.domain.dto;
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO wrapping daily password data with metadata.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record DailyPasswordResponse(
|
public record DailyPasswordResponse(
|
||||||
String status,
|
String status,
|
||||||
String message,
|
String message,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.onixbyte.deltaforceguide.domain.dto;
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error response body returned on API failures.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record ErrorResponse(
|
public record ErrorResponse(
|
||||||
String message
|
String message
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
|
|||||||
|
|
||||||
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
|
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating or updating a firearm.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record FirearmRequest(
|
public record FirearmRequest(
|
||||||
String name,
|
String name,
|
||||||
FirearmType type,
|
FirearmType type,
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
|
|||||||
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
|
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
|
||||||
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
|
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for a firearm record, including associated modifications.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record FirearmResponse(
|
public record FirearmResponse(
|
||||||
Long id,
|
Long id,
|
||||||
String name,
|
String name,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record GitHubIssueRequest(
|
||||||
|
String action,
|
||||||
|
GitHubWebhookIssue issue,
|
||||||
|
GitHubWebhookRepository repository,
|
||||||
|
GitHubWebhookSender sender
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record GitHubWebhookIssue(
|
||||||
|
String url,
|
||||||
|
Long id,
|
||||||
|
Long number,
|
||||||
|
String title,
|
||||||
|
String body,
|
||||||
|
List<GitHubWebhookLabel> labels
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record GitHubWebhookLabel(
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record GitHubWebhookRepository(
|
||||||
|
String fullName
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record GitHubWebhookSender(
|
||||||
|
String login
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login request containing principle (username/email) and credential (password).
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Schema(description = "登录请求")
|
@Schema(description = "登录请求")
|
||||||
public record LoginRequest(
|
public record LoginRequest(
|
||||||
@NotBlank(message = "登录名称不能为空")
|
@NotBlank(message = "登录名称不能为空")
|
||||||
|
|||||||
+5
@@ -5,6 +5,11 @@ import jakarta.validation.constraints.NotEmpty;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for batch creation of modifications.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record ModificationBatchCreateRequest(
|
public record ModificationBatchCreateRequest(
|
||||||
@NotEmpty(message = "批量创建列表不能为空")
|
@NotEmpty(message = "批量创建列表不能为空")
|
||||||
List<@Valid ModificationRequest> modifications
|
List<@Valid ModificationRequest> modifications
|
||||||
|
|||||||
+5
@@ -5,6 +5,11 @@ import jakarta.validation.constraints.Positive;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for batch deletion of modifications by ID.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record ModificationBatchDeleteRequest(
|
public record ModificationBatchDeleteRequest(
|
||||||
@NotEmpty(message = "批量删除ID列表不能为空")
|
@NotEmpty(message = "批量删除ID列表不能为空")
|
||||||
List<@Positive(message = "ID必须为正数") Long> ids
|
List<@Positive(message = "ID必须为正数") Long> ids
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import jakarta.validation.constraints.Positive;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating or updating a modification.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record ModificationRequest(
|
public record ModificationRequest(
|
||||||
@NotNull(message = "武器ID不能为空")
|
@NotNull(message = "武器ID不能为空")
|
||||||
@Positive(message = "武器ID必须为正数")
|
@Positive(message = "武器ID必须为正数")
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for a modification record including accessories and tags.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record ModificationResponse(
|
public record ModificationResponse(
|
||||||
Long id,
|
Long id,
|
||||||
Long firearmId,
|
Long firearmId,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import org.springframework.data.domain.Page;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic paginated response wrapper for list endpoints.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record PageResponse<T>(
|
public record PageResponse<T>(
|
||||||
List<T> items,
|
List<T> items,
|
||||||
int page,
|
int page,
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for a tuning adjustment on an accessory.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record TuningRequest(
|
public record TuningRequest(
|
||||||
@NotBlank(message = "调校项名称不能为空")
|
@NotBlank(message = "调校项名称不能为空")
|
||||||
String tuningName,
|
String tuningName,
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
|
|||||||
|
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
|
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for a tuning adjustment on an accessory.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record TuningResponse(
|
public record TuningResponse(
|
||||||
String tuningName,
|
String tuningName,
|
||||||
Double tuningValue
|
Double tuningValue
|
||||||
|
|||||||
@@ -2,16 +2,25 @@ package com.onixbyte.deltaforceguide.domain.dto;
|
|||||||
|
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.User;
|
import com.onixbyte.deltaforceguide.domain.entity.User;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for a user account, including associated credentials.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public record UserResponse(
|
public record UserResponse(
|
||||||
Long id,
|
Long id,
|
||||||
String username,
|
String username,
|
||||||
String email
|
String email,
|
||||||
|
LocalDateTime expiration
|
||||||
) {
|
) {
|
||||||
public static UserResponse from(User user) {
|
public static UserResponse from(User user, LocalDateTime expiration) {
|
||||||
return new UserResponse(
|
return new UserResponse(
|
||||||
user.getId(),
|
user.getId(),
|
||||||
user.getUsername(),
|
user.getUsername(),
|
||||||
user.getEmail()
|
user.getEmail(),
|
||||||
|
expiration
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ package com.onixbyte.deltaforceguide.domain.entity;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing an accessory attached to a modification, stored as JSONB.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class Accessory {
|
public class Accessory {
|
||||||
|
|
||||||
private String slotName;
|
private String slotName;
|
||||||
@@ -45,4 +51,22 @@ public class Accessory {
|
|||||||
public void removeTuning(Tuning tuning) {
|
public void removeTuning(Tuning tuning) {
|
||||||
this.tunings.remove(tuning);
|
this.tunings.remove(tuning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof Accessory accessory)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(slotName, accessory.slotName)
|
||||||
|
&& Objects.equals(accessoryName, accessory.accessoryName)
|
||||||
|
&& Objects.equals(tunings, accessory.tunings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(slotName, accessoryName, tunings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import jakarta.persistence.Table;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing a firearm in the Delta Force game.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "firearm")
|
@Table(name = "firearm")
|
||||||
public class Firearm {
|
public class Firearm {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import org.hibernate.annotations.Type;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing a firearm modification or build configuration.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(
|
@Table(
|
||||||
name = "modification",
|
name = "modification",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
package com.onixbyte.deltaforceguide.domain.entity;
|
package com.onixbyte.deltaforceguide.domain.entity;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing a tuning adjustment for an accessory, stored as JSONB within Accessory.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class Tuning {
|
public class Tuning {
|
||||||
|
|
||||||
private String tuningName;
|
private String tuningName;
|
||||||
@@ -23,4 +30,21 @@ public class Tuning {
|
|||||||
public void setTuningValue(Double tuningValue) {
|
public void setTuningValue(Double tuningValue) {
|
||||||
this.tuningValue = tuningValue;
|
this.tuningValue = tuningValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof Tuning tuning)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(tuningName, tuning.tuningName)
|
||||||
|
&& Objects.equals(tuningValue, tuning.tuningValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(tuningName, tuningValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import jakarta.persistence.Table;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing an application user with authentication credentials.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "app_user")
|
@Table(name = "app_user")
|
||||||
public class User {
|
public class User {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import jakarta.persistence.MapsId;
|
|||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing a user credential linked to an authentication provider.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "app_user_credential")
|
@Table(name = "app_user_credential")
|
||||||
public class UserCredential {
|
public class UserCredential {
|
||||||
@@ -28,7 +33,7 @@ public class UserCredential {
|
|||||||
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user"))
|
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user"))
|
||||||
private User user;
|
private User user;
|
||||||
|
|
||||||
@Column(name = "credential", nullable = false, length = 255)
|
@Column(name = "credential", nullable = false)
|
||||||
private String credential;
|
private String credential;
|
||||||
|
|
||||||
public UserCredentialId getId() {
|
public UserCredentialId getId() {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import jakarta.persistence.Embeddable;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite key for the UserCredential entity, combining user ID and provider.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Embeddable
|
@Embeddable
|
||||||
public class UserCredentialId implements Serializable {
|
public class UserCredentialId implements Serializable {
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package com.onixbyte.deltaforceguide.enumeration;
|
package com.onixbyte.deltaforceguide.enumeration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration of firearm types in the Delta Force game.
|
||||||
|
* Each type is associated with an integer code used for database persistence.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public enum FirearmType {
|
public enum FirearmType {
|
||||||
|
|
||||||
RIFLE(0),
|
RIFLE(0),
|
||||||
@@ -21,6 +27,13 @@ public enum FirearmType {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a FirearmType from its integer code.
|
||||||
|
*
|
||||||
|
* @param code the integer code, may be null
|
||||||
|
* @return the corresponding FirearmType, or null if the code is null
|
||||||
|
* @throws IllegalArgumentException if the code does not match any known type
|
||||||
|
*/
|
||||||
public static FirearmType fromCode(Integer code) {
|
public static FirearmType fromCode(Integer code) {
|
||||||
if (code == null) {
|
if (code == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.exeption;
|
|||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom runtime exception that carries an HTTP status code for API error responses.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class BizException extends RuntimeException {
|
public class BizException extends RuntimeException {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
@@ -29,6 +28,11 @@ import java.time.Instant;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
@@ -52,6 +56,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
this.handlerExceptionResolver = handlerExceptionResolver;
|
this.handlerExceptionResolver = handlerExceptionResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts JWT from httpOnly cookie, validates it, and sets the security context.
|
||||||
|
*
|
||||||
|
* @param request the HTTP request
|
||||||
|
* @param response the HTTP response
|
||||||
|
* @param filterChain the filter chain
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(
|
protected void doFilterInternal(
|
||||||
@NonNull HttpServletRequest request,
|
@NonNull HttpServletRequest request,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.filter;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper;
|
||||||
|
import jakarta.servlet.*;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class WebhookFilter implements Filter {
|
||||||
|
@Override
|
||||||
|
public void doFilter(
|
||||||
|
ServletRequest request,
|
||||||
|
ServletResponse response,
|
||||||
|
FilterChain chain
|
||||||
|
) throws IOException, ServletException {
|
||||||
|
var httpRequest = (HttpServletRequest) request;
|
||||||
|
var wrappedRequest = new RepeatedlyReadRequestWrapper(httpRequest);
|
||||||
|
chain.doFilter(wrappedRequest, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.interceptor;
|
||||||
|
|
||||||
|
import com.onixbyte.crypto.util.CryptoUtil;
|
||||||
|
import com.onixbyte.deltaforceguide.exeption.BizException;
|
||||||
|
import com.onixbyte.deltaforceguide.manager.WebhookManager;
|
||||||
|
import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader;
|
||||||
|
import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.jspecify.annotations.NonNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies GitHub webhook requests by validating the {@code X-Hub-Signature-256}
|
||||||
|
* header against the configured secret using HMAC-SHA256.
|
||||||
|
*
|
||||||
|
* <p>Verification is skipped when no secret is configured. The signature format is
|
||||||
|
* {@code sha256=<hex-digest>} as documented by GitHub.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class GitHubWebhookInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GitHubWebhookInterceptor.class);
|
||||||
|
|
||||||
|
private final WebhookManager webhookManager;
|
||||||
|
|
||||||
|
public GitHubWebhookInterceptor(WebhookManager webhookManager) {
|
||||||
|
this.webhookManager = webhookManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(
|
||||||
|
@NonNull HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull Object handler
|
||||||
|
) {
|
||||||
|
if (!(request instanceof RepeatedlyReadRequestWrapper req)) {
|
||||||
|
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Request body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
var secret = webhookManager.github().secret();
|
||||||
|
if (secret == null || secret.isBlank()) {
|
||||||
|
log.debug("No GitHub webhook secret configured, skipping signature verification");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var signatureHeader = req.getHeader(GitHubWebhookHeader.SIGNATURE_256);
|
||||||
|
if (signatureHeader == null || signatureHeader.isBlank()) {
|
||||||
|
log.warn("Missing {} header from ip={}",
|
||||||
|
GitHubWebhookHeader.SIGNATURE_256, request.getRemoteAddr());
|
||||||
|
throw new BizException(HttpStatus.UNAUTHORIZED,
|
||||||
|
"Missing webhook signature header");
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = req.getBodyString();
|
||||||
|
try {
|
||||||
|
var computed = "sha256=" + CryptoUtil.hmacSha256(body, secret);
|
||||||
|
|
||||||
|
if (!MessageDigest.isEqual(
|
||||||
|
computed.getBytes(StandardCharsets.UTF_8),
|
||||||
|
signatureHeader.getBytes(StandardCharsets.UTF_8))) {
|
||||||
|
log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr());
|
||||||
|
throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||||
|
log.error("Failed to compute HMAC-SHA256", e);
|
||||||
|
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify webhook signature");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.interceptor;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.jspecify.annotations.NonNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class TrafficInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TrafficInterceptor.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(
|
||||||
|
@NonNull HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull Object handler
|
||||||
|
) {
|
||||||
|
var ip = resolveClientIp(request);
|
||||||
|
var method = request.getMethod();
|
||||||
|
var uri = request.getRequestURI();
|
||||||
|
var query = request.getQueryString();
|
||||||
|
var contentType = request.getContentType();
|
||||||
|
var contentLength = request.getContentLength();
|
||||||
|
var userAgent = request.getHeader("User-Agent");
|
||||||
|
|
||||||
|
log.debug("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}",
|
||||||
|
method, uri, query, ip, contentType, contentLength, userAgent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveClientIp(HttpServletRequest request) {
|
||||||
|
var xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||||
|
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
|
||||||
|
return xForwardedFor.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
var xRealIp = request.getHeader("X-Real-IP");
|
||||||
|
if (xRealIp != null && !xRealIp.isBlank()) {
|
||||||
|
return xRealIp.trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager providing cookie construction operations with configurable properties.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class CookieManager {
|
public class CookieManager {
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import java.time.Duration;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for daily password data access and caching coordination.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class DailyPasswordManager {
|
public class DailyPasswordManager {
|
||||||
|
|
||||||
@@ -49,6 +54,10 @@ public class DailyPasswordManager {
|
|||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the daily password from cache or generates a new one.
|
||||||
|
* @return the daily password response
|
||||||
|
*/
|
||||||
public DailyPasswordResponse getDailyPassword() {
|
public DailyPasswordResponse getDailyPassword() {
|
||||||
var key = CACHE_KEY_PREFIX + LocalDate.now();
|
var key = CACHE_KEY_PREFIX + LocalDate.now();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.manager;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.domain.entity.Accessory;
|
||||||
|
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
|
||||||
|
import com.onixbyte.deltaforceguide.domain.entity.Modification;
|
||||||
|
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
|
||||||
|
import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest;
|
||||||
|
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
|
||||||
|
import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse;
|
||||||
|
import com.onixbyte.deltaforceguide.domain.dto.TuningRequest;
|
||||||
|
import com.onixbyte.deltaforceguide.repository.FirearmRepository;
|
||||||
|
import com.onixbyte.deltaforceguide.repository.ModificationRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ModificationManager {
|
||||||
|
|
||||||
|
private final ModificationRepository modificationRepository;
|
||||||
|
private final FirearmRepository firearmRepository;
|
||||||
|
|
||||||
|
public ModificationManager(
|
||||||
|
ModificationRepository modificationRepository,
|
||||||
|
FirearmRepository firearmRepository
|
||||||
|
) {
|
||||||
|
this.modificationRepository = modificationRepository;
|
||||||
|
this.firearmRepository = firearmRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ModificationResponse create(ModificationRequest request) {
|
||||||
|
var firearm = firearmRepository.findById(request.firearmId())
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||||
|
"Firearm not found: " + request.firearmId()));
|
||||||
|
var modification = toEntity(request, firearm);
|
||||||
|
return ModificationResponse.from(modificationRepository.save(modification));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
|
||||||
|
var 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()) {
|
||||||
|
var missing = firearmIds.stream()
|
||||||
|
.filter((id) -> !firearmMap.containsKey(id))
|
||||||
|
.toList();
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||||
|
"Firearm not found: " + missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifications = requests.stream()
|
||||||
|
.map(req -> toEntity(req, firearmMap.get(req.firearmId())))
|
||||||
|
.toList();
|
||||||
|
return modificationRepository.saveAll(modifications)
|
||||||
|
.stream()
|
||||||
|
.map(ModificationResponse::from)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long resolveFirearmId(Long firearmId, String firearmName) {
|
||||||
|
if (firearmId != null) {
|
||||||
|
return firearmId;
|
||||||
|
}
|
||||||
|
if (firearmName == null || firearmName.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var matches = firearmRepository.findByName(firearmName);
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||||
|
"Firearm not found by name: " + firearmName);
|
||||||
|
}
|
||||||
|
return matches.getFirst().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Modification toEntity(ModificationRequest request, Firearm firearm) {
|
||||||
|
return Modification.builder()
|
||||||
|
.firearm(firearm)
|
||||||
|
.name(request.name())
|
||||||
|
.code(request.code())
|
||||||
|
.tags(request.tags())
|
||||||
|
.accessories(toAccessories(request.accessories()))
|
||||||
|
.note(request.note())
|
||||||
|
.author(request.author())
|
||||||
|
.videoUrl(request.videoUrl())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Accessory> toAccessories(List<AccessoryRequest> requests) {
|
||||||
|
if (requests == null) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
return requests.stream().map(this::toAccessory).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Accessory toAccessory(AccessoryRequest request) {
|
||||||
|
var accessory = new Accessory();
|
||||||
|
accessory.setSlotName(request.slotName());
|
||||||
|
accessory.setAccessoryName(request.accessoryName());
|
||||||
|
accessory.setTunings(toTunings(request.tunings()));
|
||||||
|
return accessory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Tuning> toTunings(List<TuningRequest> requests) {
|
||||||
|
if (requests == null) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
return requests.stream().map(this::toTuning).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Tuning toTuning(TuningRequest request) {
|
||||||
|
var tuning = new Tuning();
|
||||||
|
tuning.setTuningName(request.tuningName());
|
||||||
|
tuning.setTuningValue(request.tuningValue());
|
||||||
|
return tuning;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,15 @@ package com.onixbyte.deltaforceguide.manager;
|
|||||||
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
|
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
|
||||||
import com.onixbyte.deltaforceguide.repository.UserCredentialRepository;
|
import com.onixbyte.deltaforceguide.repository.UserCredentialRepository;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for user credential persistence and authentication data access.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class UserCredentialManager {
|
public class UserCredentialManager {
|
||||||
|
|
||||||
@@ -17,27 +21,52 @@ public class UserCredentialManager {
|
|||||||
this.userCredentialRepository = userCredentialRepository;
|
this.userCredentialRepository = userCredentialRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds all credentials belonging to a specific user.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return list of matching credentials
|
||||||
|
*/
|
||||||
public List<UserCredential> findAllByUserId(Long userId) {
|
public List<UserCredential> findAllByUserId(Long userId) {
|
||||||
return userCredentialRepository.findAllByUserId(userId);
|
return userCredentialRepository.findAllByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds a credential for a specific user and provider combination.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param provider the authentication provider
|
||||||
|
* @return the matching credential, if found
|
||||||
|
*/
|
||||||
public Optional<UserCredential> findByUserIdAndProvider(Long userId, String provider) {
|
public Optional<UserCredential> findByUserIdAndProvider(Long userId, String provider) {
|
||||||
return userCredentialRepository.findByUserIdAndProvider(userId, provider);
|
return userCredentialRepository.findByUserIdAndProvider(userId, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Persists a new or updated credential.
|
||||||
|
*
|
||||||
|
* @param userCredential the credential to save
|
||||||
|
* @return the saved credential
|
||||||
|
*/
|
||||||
public UserCredential save(UserCredential userCredential) {
|
public UserCredential save(UserCredential userCredential) {
|
||||||
return userCredentialRepository.save(userCredential);
|
return userCredentialRepository.save(userCredential);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes a credential for a specific user and provider.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param provider the authentication provider
|
||||||
|
*/
|
||||||
public void deleteByUserIdAndProvider(Long userId, String provider) {
|
public void deleteByUserIdAndProvider(Long userId, String provider) {
|
||||||
userCredentialRepository.deleteByUserIdAndProvider(userId, provider);
|
userCredentialRepository.deleteByUserIdAndProvider(userId, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes all credentials belonging to a user.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
*/
|
||||||
public void deleteAllByUserId(Long userId) {
|
public void deleteAllByUserId(Long userId) {
|
||||||
userCredentialRepository.deleteAllByUserId(userId);
|
userCredentialRepository.deleteAllByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for user entity persistence and query operations.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class UserManager {
|
public class UserManager {
|
||||||
|
|
||||||
@@ -17,17 +22,30 @@ public class UserManager {
|
|||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds a user by their ID.
|
||||||
|
*
|
||||||
|
* @param id the user ID
|
||||||
|
* @return the matching user, if found
|
||||||
|
*/
|
||||||
public Optional<User> findById(Long id) {
|
public Optional<User> findById(Long id) {
|
||||||
return userRepository.findById(id);
|
return userRepository.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Retrieves all registered users.
|
||||||
|
* @return list of all users
|
||||||
|
*/
|
||||||
public List<User> findAll() {
|
public List<User> findAll() {
|
||||||
return userRepository.findAll();
|
return userRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds a user by their username.
|
||||||
|
*
|
||||||
|
* @param username the username to search for
|
||||||
|
* @return the matching user, if found
|
||||||
|
*/
|
||||||
public Optional<User> findByUsername(String username) {
|
public Optional<User> findByUsername(String username) {
|
||||||
return userRepository.findByUsername(username);
|
return userRepository.findByUsername(username);
|
||||||
}
|
}
|
||||||
@@ -37,16 +55,31 @@ public class UserManager {
|
|||||||
return userRepository.findByEmail(email);
|
return userRepository.findByEmail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Persists a new or updated user.
|
||||||
|
*
|
||||||
|
* @param user the user to save
|
||||||
|
* @return the saved user
|
||||||
|
*/
|
||||||
public User save(User user) {
|
public User save(User user) {
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes a user by their ID.
|
||||||
|
*
|
||||||
|
* @param id the user ID to delete
|
||||||
|
*/
|
||||||
public void deleteById(Long id) {
|
public void deleteById(Long id) {
|
||||||
userRepository.deleteById(id);
|
userRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by their username or email address.
|
||||||
|
*
|
||||||
|
* @param principal the username or email to search for
|
||||||
|
* @return the matching user, if found
|
||||||
|
*/
|
||||||
public Optional<User> findByUsernameOrEmail(String principal) {
|
public Optional<User> findByUsernameOrEmail(String principal) {
|
||||||
return userRepository.findByUsernameOrEmail(principal);
|
return userRepository.findByUsernameOrEmail(principal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.manager;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties;
|
||||||
|
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class WebhookManager {
|
||||||
|
|
||||||
|
private final WebhookProperties webhookProperties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public WebhookManager(WebhookProperties webhookProperties) {
|
||||||
|
this.webhookProperties = webhookProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GitHubWebhookProperties github() {
|
||||||
|
return webhookProperties.github();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,16 @@ import org.springframework.boot.web.server.Cookie;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration properties for HTTP cookies used in authentication, prefixed with "app.cookie".
|
||||||
|
*
|
||||||
|
* @param httpOnly whether the cookie is httpOnly
|
||||||
|
* @param secure whether the cookie is secure
|
||||||
|
* @param path the cookie path
|
||||||
|
* @param maxAge the maximum age of the cookie
|
||||||
|
* @param sameSite the SameSite policy for the cookie
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@ConfigurationProperties(prefix = "app.cookie")
|
@ConfigurationProperties(prefix = "app.cookie")
|
||||||
public record CookieProperties(
|
public record CookieProperties(
|
||||||
@DefaultValue("true") Boolean httpOnly,
|
@DefaultValue("true") Boolean httpOnly,
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import org.springframework.http.HttpMethod;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration properties for CORS settings, prefixed with "app.cors".
|
||||||
|
*
|
||||||
|
* @param allowedHeaders headers allowed in CORS requests
|
||||||
|
* @param allowedMethods HTTP methods allowed in CORS requests
|
||||||
|
* @param allowedOrigins origins permitted to make cross-origin requests
|
||||||
|
* @param allowCredentials whether credentials are allowed in CORS requests
|
||||||
|
* @param allowPrivateNetwork whether private network access is permitted
|
||||||
|
* @param maxAge how long the CORS preflight response may be cached
|
||||||
|
* @param exposedHeaders headers exposed to the client in CORS responses
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@ConfigurationProperties(prefix = "app.cors")
|
@ConfigurationProperties(prefix = "app.cors")
|
||||||
public record CorsProperties(
|
public record CorsProperties(
|
||||||
@DefaultValue({"Content-Type", "Authorization"})
|
@DefaultValue({"Content-Type", "Authorization"})
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.properties;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record GitHubWebhookProperties(
|
||||||
|
String secret,
|
||||||
|
List<String> allowedUsers
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration properties for JWT token generation and validation, prefixed with "app.jwt".
|
||||||
|
*
|
||||||
|
* @param issuer the JWT issuer claim
|
||||||
|
* @param secret the signing secret for JWT tokens
|
||||||
|
* @param validTime the duration for which a token remains valid
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@ConfigurationProperties(prefix = "app.jwt")
|
@ConfigurationProperties(prefix = "app.jwt")
|
||||||
public record TokenProperties(
|
public record TokenProperties(
|
||||||
String issuer,
|
String issuer,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.properties;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "app.webhook")
|
||||||
|
public record WebhookProperties(
|
||||||
|
GitHubWebhookProperties github
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -7,9 +7,18 @@ import org.springframework.data.domain.Pageable;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Data JPA repository for {@link Firearm} entity operations.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface FirearmRepository extends JpaRepository<Firearm, Long> {
|
public interface FirearmRepository extends JpaRepository<Firearm, Long> {
|
||||||
|
|
||||||
Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
|
Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
|
||||||
|
|
||||||
|
List<Firearm> findByName(String name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Data JPA repository for {@link Modification} entity operations,
|
||||||
|
* including native JSONB tag filtering for Postgres.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface ModificationRepository extends JpaRepository<Modification, Long> {
|
public interface ModificationRepository extends JpaRepository<Modification, Long> {
|
||||||
|
|
||||||
@@ -27,6 +33,14 @@ public interface ModificationRepository extends JpaRepository<Modification, Long
|
|||||||
@NonNull
|
@NonNull
|
||||||
Optional<Modification> findById(@NonNull Long id);
|
Optional<Modification> findById(@NonNull Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page query modifications with optional firearm and JSONB tag filtering.
|
||||||
|
*
|
||||||
|
* @param firearmId optional firearm ID filter (nullable)
|
||||||
|
* @param tagsJson optional JSON array of tags to match via Postgres {@code @>} operator (nullable)
|
||||||
|
* @param pageable pagination information
|
||||||
|
* @return a page of matching modifications
|
||||||
|
*/
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT * FROM modification m
|
SELECT * FROM modification m
|
||||||
WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId)
|
WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId)
|
||||||
@@ -40,6 +54,12 @@ public interface ModificationRepository extends JpaRepository<Modification, Long
|
|||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
Page<Modification> pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable);
|
Page<Modification> pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all distinct tag values from modifications, optionally filtered by firearm.
|
||||||
|
*
|
||||||
|
* @param firearmId optional firearm ID filter (nullable)
|
||||||
|
* @return list of distinct tag strings
|
||||||
|
*/
|
||||||
@Query(value = "SELECT DISTINCT jsonb_array_elements_text(cast(tags as jsonb)) FROM modification WHERE (:firearmId IS NULL OR firearm_id = :firearmId)", nativeQuery = true)
|
@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);
|
List<String> findAllTags(@Param("firearmId") Long firearmId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Data JPA repository for {@link UserCredential} entity operations.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserCredentialRepository extends JpaRepository<UserCredential, UserCredentialId> {
|
public interface UserCredentialRepository extends JpaRepository<UserCredential, UserCredentialId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all credentials belonging to a given user.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return list of matching credentials
|
||||||
|
*/
|
||||||
@EntityGraph(attributePaths = {"user"})
|
@EntityGraph(attributePaths = {"user"})
|
||||||
@Query("""
|
@Query("""
|
||||||
select uc
|
select uc
|
||||||
@@ -23,6 +34,13 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
|
|||||||
""")
|
""")
|
||||||
List<UserCredential> findAllByUserId(@Param("userId") Long userId);
|
List<UserCredential> findAllByUserId(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific credential for a user by provider.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param provider the authentication provider identifier
|
||||||
|
* @return an optional containing the matching credential, or empty if not found
|
||||||
|
*/
|
||||||
@EntityGraph(attributePaths = {"user"})
|
@EntityGraph(attributePaths = {"user"})
|
||||||
@Query("""
|
@Query("""
|
||||||
select uc
|
select uc
|
||||||
@@ -32,6 +50,12 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
|
|||||||
""")
|
""")
|
||||||
Optional<UserCredential> findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
|
Optional<UserCredential> findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific credential for a user by provider.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param provider the authentication provider identifier
|
||||||
|
*/
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("""
|
@Query("""
|
||||||
delete from UserCredential uc
|
delete from UserCredential uc
|
||||||
@@ -40,6 +64,11 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
|
|||||||
""")
|
""")
|
||||||
void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
|
void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all credentials for a given user.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
*/
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("""
|
@Query("""
|
||||||
delete from UserCredential uc
|
delete from UserCredential uc
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Data JPA repository for {@link User} entity operations.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
|
||||||
@@ -28,6 +33,12 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
|
|
||||||
boolean existsByEmail(String email);
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a user by either username or email.
|
||||||
|
*
|
||||||
|
* @param principal the username or email to search for
|
||||||
|
* @return an optional containing the matching user, or empty if not found
|
||||||
|
*/
|
||||||
@EntityGraph(attributePaths = {"credentials"})
|
@EntityGraph(attributePaths = {"credentials"})
|
||||||
@Query("""
|
@Query("""
|
||||||
select u
|
select u
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation to mark controller endpoints that require authentication.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|||||||
+5
@@ -8,6 +8,11 @@ import org.springframework.security.core.GrantedAuthority;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom authentication token for username/password-based login flows.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer {
|
public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer {
|
||||||
private final String username;
|
private final String username;
|
||||||
private String password;
|
private String password;
|
||||||
|
|||||||
+17
@@ -17,6 +17,11 @@ import org.springframework.security.core.AuthenticationException;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication provider that validates username/password credentials against stored BCrypt hashes.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
|
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
|
||||||
|
|
||||||
@@ -36,6 +41,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro
|
|||||||
this.userCredentialRepository = userCredentialRepository;
|
this.userCredentialRepository = userCredentialRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the username/password credentials against stored BCrypt hashes.
|
||||||
|
*
|
||||||
|
* @param authentication the authentication request object
|
||||||
|
* @return a fully authenticated object including user details
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||||
if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) {
|
if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) {
|
||||||
@@ -75,6 +86,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro
|
|||||||
return usernamePasswordAuthentication;
|
return usernamePasswordAuthentication;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this provider supports the given authentication type.
|
||||||
|
*
|
||||||
|
* @param authentication the authentication class to check
|
||||||
|
* @return true if this provider supports the given type
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean supports(Class<?> authentication) {
|
public boolean supports(Class<?> authentication) {
|
||||||
return UsernamePasswordAuthentication.class.isAssignableFrom(authentication);
|
return UsernamePasswordAuthentication.class.isAssignableFrom(authentication);
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Objects;
|
/**
|
||||||
import java.util.Optional;
|
* Service handling user authentication, login, and session management.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
@@ -23,6 +25,16 @@ public class AuthService {
|
|||||||
this.authenticationManager = authenticationManager;
|
this.authenticationManager = authenticationManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a user with the given login credentials.
|
||||||
|
* <p>
|
||||||
|
* Delegates authentication to Spring Security's {@link AuthenticationManager} and verifies
|
||||||
|
* that the result is of the expected {@link UsernamePasswordAuthentication} type.
|
||||||
|
*
|
||||||
|
* @param request the login credentials containing principle and password
|
||||||
|
* @return the authenticated {@link User}
|
||||||
|
* @throws BizException if authentication fails or the result type is unexpected
|
||||||
|
*/
|
||||||
public User login(LoginRequest request) {
|
public User login(LoginRequest request) {
|
||||||
var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication
|
var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication
|
||||||
.unauthenticated(request.principle(), request.credential()));
|
.unauthenticated(request.principle(), request.credential()));
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for building HTTP cookies with configurable properties.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class CookieService {
|
public class CookieService {
|
||||||
|
|
||||||
@@ -15,10 +20,25 @@ public class CookieService {
|
|||||||
this.cookieManager = cookieManager;
|
this.cookieManager = cookieManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a response cookie with the default max age from configuration.
|
||||||
|
*
|
||||||
|
* @param cookieName the cookie name
|
||||||
|
* @param value the cookie value
|
||||||
|
* @return a configured ResponseCookie
|
||||||
|
*/
|
||||||
public ResponseCookie buildCookie(String cookieName, String value) {
|
public ResponseCookie buildCookie(String cookieName, String value) {
|
||||||
return buildCookieInternal(cookieName, value, cookieManager.getMaxAge());
|
return buildCookieInternal(cookieName, value, cookieManager.getMaxAge());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a response cookie with a custom valid duration.
|
||||||
|
*
|
||||||
|
* @param cookieName the cookie name
|
||||||
|
* @param value the cookie value
|
||||||
|
* @param validDuration the cookie's max age
|
||||||
|
* @return a configured ResponseCookie
|
||||||
|
*/
|
||||||
public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) {
|
public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) {
|
||||||
return buildCookieInternal(cookieName, value, validDuration);
|
return buildCookieInternal(cookieName, value, validDuration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse;
|
|||||||
import com.onixbyte.deltaforceguide.manager.DailyPasswordManager;
|
import com.onixbyte.deltaforceguide.manager.DailyPasswordManager;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for generating and caching daily rotation passwords.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class DailyPasswordService {
|
public class DailyPasswordService {
|
||||||
|
|
||||||
@@ -13,6 +18,10 @@ public class DailyPasswordService {
|
|||||||
this.dailyPasswordManager = dailyPasswordManager;
|
this.dailyPasswordManager = dailyPasswordManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the daily password for the current day.
|
||||||
|
* @return the daily password response
|
||||||
|
*/
|
||||||
public DailyPasswordResponse getDailyPassword() {
|
public DailyPasswordResponse getDailyPassword() {
|
||||||
return dailyPasswordManager.getDailyPassword();
|
return dailyPasswordManager.getDailyPassword();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service handling firearm business logic including CRUD operations and queries.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class FirearmService {
|
public class FirearmService {
|
||||||
|
|
||||||
@@ -23,7 +27,13 @@ public class FirearmService {
|
|||||||
this.firearmRepository = firearmRepository;
|
this.firearmRepository = firearmRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Queries firearms with optional type filter and pagination.
|
||||||
|
*
|
||||||
|
* @param type optional firearm type filter
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return a paginated response of firearm records
|
||||||
|
*/
|
||||||
public PageResponse<FirearmResponse> pageQuery(FirearmType type, Pageable pageable) {
|
public PageResponse<FirearmResponse> pageQuery(FirearmType type, Pageable pageable) {
|
||||||
Page<Firearm> page = type == null
|
Page<Firearm> page = type == null
|
||||||
? firearmRepository.findAll(pageable)
|
? firearmRepository.findAll(pageable)
|
||||||
@@ -32,13 +42,24 @@ public class FirearmService {
|
|||||||
return PageResponse.from(page.map(FirearmResponse::from));
|
return PageResponse.from(page.map(FirearmResponse::from));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds a firearm by its ID.
|
||||||
|
*
|
||||||
|
* @param id the firearm ID
|
||||||
|
* @return the firearm response
|
||||||
|
*/
|
||||||
public FirearmResponse queryById(Long id) {
|
public FirearmResponse queryById(Long id) {
|
||||||
return firearmRepository.findById(id)
|
return firearmRepository.findById(id)
|
||||||
.map(FirearmResponse::from)
|
.map(FirearmResponse::from)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new firearm from the provided request data.
|
||||||
|
*
|
||||||
|
* @param request the firearm creation request
|
||||||
|
* @return the created firearm response
|
||||||
|
*/
|
||||||
public FirearmResponse addFirearm(FirearmRequest request) {
|
public FirearmResponse addFirearm(FirearmRequest request) {
|
||||||
var firearm = firearmRepository.save(Firearm.builder()
|
var firearm = firearmRepository.save(Firearm.builder()
|
||||||
.name(request.name())
|
.name(request.name())
|
||||||
@@ -54,7 +75,13 @@ public class FirearmService {
|
|||||||
return FirearmResponse.from(firearm);
|
return FirearmResponse.from(firearm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Updates an existing firearm identified by ID.
|
||||||
|
*
|
||||||
|
* @param id the firearm ID
|
||||||
|
* @param request the updated firearm data
|
||||||
|
* @return the updated firearm response
|
||||||
|
*/
|
||||||
public FirearmResponse updateFirearm(Long id, FirearmRequest request) {
|
public FirearmResponse updateFirearm(Long id, FirearmRequest request) {
|
||||||
var firearm = firearmRepository.findById(id)
|
var firearm = firearmRepository.findById(id)
|
||||||
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
|
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
|
||||||
@@ -71,7 +98,11 @@ public class FirearmService {
|
|||||||
return FirearmResponse.from(firearmRepository.save(firearm));
|
return FirearmResponse.from(firearmRepository.save(firearm));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes a firearm by its ID.
|
||||||
|
*
|
||||||
|
* @param id the firearm ID to delete
|
||||||
|
*/
|
||||||
public void deleteFirearm(Long id) {
|
public void deleteFirearm(Long id) {
|
||||||
Firearm firearm = firearmRepository.findById(id)
|
Firearm firearm = firearmRepository.findById(id)
|
||||||
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
|
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory;
|
|||||||
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
|
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.Modification;
|
import com.onixbyte.deltaforceguide.domain.entity.Modification;
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
|
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
|
||||||
|
import com.onixbyte.deltaforceguide.manager.ModificationManager;
|
||||||
import com.onixbyte.deltaforceguide.repository.FirearmRepository;
|
import com.onixbyte.deltaforceguide.repository.FirearmRepository;
|
||||||
import com.onixbyte.deltaforceguide.repository.ModificationRepository;
|
import com.onixbyte.deltaforceguide.repository.ModificationRepository;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
@@ -17,34 +18,46 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service handling modification business logic including CRUD, batch operations, and tag filtering.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class ModificationService {
|
public class ModificationService {
|
||||||
|
|
||||||
private final ModificationRepository modificationRepository;
|
private final ModificationRepository modificationRepository;
|
||||||
private final FirearmRepository firearmRepository;
|
private final FirearmRepository firearmRepository;
|
||||||
|
private final ModificationManager modificationManager;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ModificationService(
|
public ModificationService(
|
||||||
ModificationRepository modificationRepository,
|
ModificationRepository modificationRepository,
|
||||||
FirearmRepository firearmRepository,
|
FirearmRepository firearmRepository,
|
||||||
|
ModificationManager modificationManager,
|
||||||
ObjectMapper objectMapper
|
ObjectMapper objectMapper
|
||||||
) {
|
) {
|
||||||
this.modificationRepository = modificationRepository;
|
this.modificationRepository = modificationRepository;
|
||||||
this.firearmRepository = firearmRepository;
|
this.firearmRepository = firearmRepository;
|
||||||
|
this.modificationManager = modificationManager;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Queries modifications with optional firearm and tag filters.
|
||||||
|
*
|
||||||
|
* @param firearmId optional firearm ID filter
|
||||||
|
* @param tags optional tag list filter
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return a paginated response of modification records
|
||||||
|
*/
|
||||||
public PageResponse<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) {
|
public PageResponse<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) {
|
||||||
String tagsJson = null;
|
String tagsJson = null;
|
||||||
if (tags != null && !tags.isEmpty()) {
|
if (tags != null && !tags.isEmpty()) {
|
||||||
@@ -65,53 +78,55 @@ public class ModificationService {
|
|||||||
return PageResponse.from(page.map(ModificationResponse::from));
|
return PageResponse.from(page.map(ModificationResponse::from));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds a modification by its ID.
|
||||||
|
*
|
||||||
|
* @param id the modification ID
|
||||||
|
* @return the modification response
|
||||||
|
*/
|
||||||
public ModificationResponse queryById(Long id) {
|
public ModificationResponse queryById(Long id) {
|
||||||
return modificationRepository.findById(id)
|
return modificationRepository.findById(id)
|
||||||
.map(ModificationResponse::from)
|
.map(ModificationResponse::from)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds all unique tags across modifications, optionally scoped to a firearm.
|
||||||
|
*
|
||||||
|
* @param firearmId optional firearm ID to scope the tag search
|
||||||
|
* @return list of unique tag strings
|
||||||
|
*/
|
||||||
public List<String> findAllTags(Long firearmId) {
|
public List<String> findAllTags(Long firearmId) {
|
||||||
return modificationRepository.findAllTags(firearmId);
|
return modificationRepository.findAllTags(firearmId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Creates a new modification for a given firearm.
|
||||||
|
*
|
||||||
|
* @param request the modification creation request
|
||||||
|
* @return the created modification response
|
||||||
|
*/
|
||||||
public ModificationResponse create(ModificationRequest request) {
|
public ModificationResponse create(ModificationRequest request) {
|
||||||
Firearm firearm = firearmRepository.findById(request.firearmId())
|
return modificationManager.create(request);
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId()));
|
|
||||||
|
|
||||||
Modification modification = toEntity(request, firearm);
|
|
||||||
return ModificationResponse.from(modificationRepository.save(modification));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Creates multiple modifications in a single batch operation.
|
||||||
|
*
|
||||||
|
* @param requests list of modification creation requests
|
||||||
|
* @return list of created modification responses
|
||||||
|
*/
|
||||||
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
|
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
|
||||||
Set<Long> firearmIds = requests.stream()
|
return modificationManager.batchCreate(requests);
|
||||||
.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
|
/**
|
||||||
|
* Updates an existing modification identified by ID.
|
||||||
|
*
|
||||||
|
* @param id the modification ID
|
||||||
|
* @param request the updated modification data
|
||||||
|
* @return the updated modification response
|
||||||
|
*/
|
||||||
public ModificationResponse update(Long id, ModificationRequest request) {
|
public ModificationResponse update(Long id, ModificationRequest request) {
|
||||||
Modification modification = modificationRepository.findById(id)
|
Modification modification = modificationRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
|
||||||
@@ -130,14 +145,22 @@ public class ModificationService {
|
|||||||
return ModificationResponse.from(modificationRepository.save(modification));
|
return ModificationResponse.from(modificationRepository.save(modification));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes a modification by its ID.
|
||||||
|
*
|
||||||
|
* @param id the modification ID to delete
|
||||||
|
*/
|
||||||
public void delete(Long id) {
|
public void delete(Long id) {
|
||||||
Modification modification = modificationRepository.findById(id)
|
Modification modification = modificationRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
|
||||||
modificationRepository.delete(modification);
|
modificationRepository.delete(modification);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes multiple modifications in a single batch operation.
|
||||||
|
*
|
||||||
|
* @param ids list of modification IDs to delete
|
||||||
|
*/
|
||||||
public void batchDelete(List<Long> ids) {
|
public void batchDelete(List<Long> ids) {
|
||||||
Set<Long> uniqueIds = new LinkedHashSet<>(ids);
|
Set<Long> uniqueIds = new LinkedHashSet<>(ids);
|
||||||
List<Modification> modifications = modificationRepository.findAllById(uniqueIds);
|
List<Modification> modifications = modificationRepository.findAllById(uniqueIds);
|
||||||
@@ -155,19 +178,6 @@ public class ModificationService {
|
|||||||
modificationRepository.deleteAllInBatch(modifications);
|
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) {
|
private List<String> safeTags(List<String> tags) {
|
||||||
return tags == null ? new ArrayList<>() : tags;
|
return tags == null ? new ArrayList<>() : tags;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import com.onixbyte.deltaforceguide.manager.UserCredentialManager;
|
|||||||
import com.onixbyte.deltaforceguide.manager.UserManager;
|
import com.onixbyte.deltaforceguide.manager.UserManager;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for user account management and profile operations.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
|
|
||||||
@@ -22,29 +26,53 @@ public class UserService {
|
|||||||
this.userCredentialManager = userCredentialManager;
|
this.userCredentialManager = userCredentialManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Retrieves all registered users.
|
||||||
|
*
|
||||||
|
* @return list of all users
|
||||||
|
*/
|
||||||
public List<User> findAll() {
|
public List<User> findAll() {
|
||||||
return userManager.findAll();
|
return userManager.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds a user by their ID.
|
||||||
|
*
|
||||||
|
* @param id the user ID
|
||||||
|
* @return the user
|
||||||
|
*/
|
||||||
public User queryById(Long id) {
|
public User queryById(Long id) {
|
||||||
return userManager.findById(id)
|
return userManager.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds a user by their username.
|
||||||
|
*
|
||||||
|
* @param username the username to search for
|
||||||
|
* @return the user
|
||||||
|
*/
|
||||||
public User queryByUsername(String username) {
|
public User queryByUsername(String username) {
|
||||||
return userManager.findByUsername(username)
|
return userManager.findByUsername(username)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + username));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + username));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Creates a new user account.
|
||||||
|
*
|
||||||
|
* @param user the user entity to persist
|
||||||
|
* @return the saved user entity
|
||||||
|
*/
|
||||||
public User create(User user) {
|
public User create(User user) {
|
||||||
return userManager.save(user);
|
return userManager.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Updates an existing user account.
|
||||||
|
*
|
||||||
|
* @param user the user entity with updated fields
|
||||||
|
* @return the saved user entity
|
||||||
|
*/
|
||||||
public User update(User user) {
|
public User update(User user) {
|
||||||
if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) {
|
if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId());
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId());
|
||||||
@@ -52,13 +80,24 @@ public class UserService {
|
|||||||
return userManager.save(user);
|
return userManager.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Finds all credentials associated with a user.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return list of user credentials
|
||||||
|
*/
|
||||||
public List<UserCredential> findCredentials(Long userId) {
|
public List<UserCredential> findCredentials(Long userId) {
|
||||||
ensureUserExists(userId);
|
ensureUserExists(userId);
|
||||||
return userCredentialManager.findAllByUserId(userId);
|
return userCredentialManager.findAllByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
/**
|
||||||
|
* Queries a specific credential for a user by provider.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param provider the authentication provider
|
||||||
|
* @return the matching credential
|
||||||
|
*/
|
||||||
public UserCredential queryCredential(Long userId, String provider) {
|
public UserCredential queryCredential(Long userId, String provider) {
|
||||||
ensureUserExists(userId);
|
ensureUserExists(userId);
|
||||||
return userCredentialManager.findByUserIdAndProvider(userId, provider)
|
return userCredentialManager.findByUserIdAndProvider(userId, provider)
|
||||||
@@ -67,7 +106,14 @@ public class UserService {
|
|||||||
"User credential not found: userId=" + userId + ", provider=" + provider));
|
"User credential not found: userId=" + userId + ", provider=" + provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Creates or updates a credential for a user and provider.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param provider the authentication provider
|
||||||
|
* @param credential the credential value
|
||||||
|
* @return the saved credential
|
||||||
|
*/
|
||||||
public UserCredential upsertCredential(Long userId, String provider, String credential) {
|
public UserCredential upsertCredential(Long userId, String provider, String credential) {
|
||||||
User user = ensureUserExists(userId);
|
User user = ensureUserExists(userId);
|
||||||
UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider)
|
UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider)
|
||||||
@@ -78,13 +124,22 @@ public class UserService {
|
|||||||
return userCredentialManager.save(userCredential);
|
return userCredentialManager.save(userCredential);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes a specific credential for a user by provider.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param provider the authentication provider
|
||||||
|
*/
|
||||||
public void deleteCredential(Long userId, String provider) {
|
public void deleteCredential(Long userId, String provider) {
|
||||||
ensureUserExists(userId);
|
ensureUserExists(userId);
|
||||||
userCredentialManager.deleteByUserIdAndProvider(userId, provider);
|
userCredentialManager.deleteByUserIdAndProvider(userId, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
/**
|
||||||
|
* Deletes a user and all associated credentials.
|
||||||
|
*
|
||||||
|
* @param id the user ID to delete
|
||||||
|
*/
|
||||||
public void deleteById(Long id) {
|
public void deleteById(Long id) {
|
||||||
ensureUserExists(id);
|
ensureUserExists(id);
|
||||||
userCredentialManager.deleteAllByUserId(id);
|
userCredentialManager.deleteAllByUserId(id);
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.service;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.domain.dto.*;
|
||||||
|
import com.onixbyte.deltaforceguide.manager.ModificationManager;
|
||||||
|
import com.onixbyte.deltaforceguide.manager.WebhookManager;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.onixbyte.deltaforceguide.exeption.BizException;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WebhookService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
|
||||||
|
private static final String TRIGGER_LABEL = "weapon-mod";
|
||||||
|
private static final Duration DEDUP_TTL = Duration.ofHours(12);
|
||||||
|
private static final Pattern YAML_FENCE =
|
||||||
|
Pattern.compile("```ya?ml\\s*\\R(.*?)```", Pattern.DOTALL);
|
||||||
|
|
||||||
|
private final ModificationManager modificationManager;
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
private final WebhookManager webhookManager;
|
||||||
|
private final Yaml yaml;
|
||||||
|
|
||||||
|
public WebhookService(
|
||||||
|
ModificationManager modificationManager,
|
||||||
|
RedisTemplate<String, Object> redisTemplate,
|
||||||
|
WebhookManager webhookManager
|
||||||
|
) {
|
||||||
|
this.modificationManager = modificationManager;
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.webhookManager = webhookManager;
|
||||||
|
this.yaml = new Yaml();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void processIssueEvent(GitHubIssueRequest request) {
|
||||||
|
var issue = request.issue();
|
||||||
|
var repository = request.repository();
|
||||||
|
var sender = request.sender();
|
||||||
|
|
||||||
|
if (!isAllowedSender(sender)) {
|
||||||
|
log.info("Issue #{} sender={} not in allowed-users, skipping",
|
||||||
|
issue.number(), sender != null ? sender.login() : "null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTriggerLabel(issue.labels())) {
|
||||||
|
log.debug("Issue #{} lacks trigger label, skipping", issue.number());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dedupKey = "github:webhook:processed:%s:%d"
|
||||||
|
.formatted(repository.fullName(), issue.number());
|
||||||
|
var acquired = redisTemplate.opsForValue()
|
||||||
|
.setIfAbsent(dedupKey, "1", DEDUP_TTL);
|
||||||
|
if (acquired == null || !acquired) {
|
||||||
|
log.info("Issue #{} already processed, skipping", issue.number());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedYaml = extractYaml(issue.body());
|
||||||
|
if (parsedYaml == null) {
|
||||||
|
log.warn("No YAML block found in issue #{} body", issue.number());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = yaml.<Map<String, Object>>load(parsedYaml);
|
||||||
|
if (data == null) {
|
||||||
|
log.warn("Empty YAML block in issue #{}", issue.number());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.containsKey("modifications")) {
|
||||||
|
processBatch(issue.number(), data);
|
||||||
|
} else {
|
||||||
|
processSingle(issue.number(), data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processSingle(Long issueNumber, Map<String, Object> data) {
|
||||||
|
var request = mapToRequest(data);
|
||||||
|
log.info("Creating modification from issue #{}: name={}", issueNumber, request.name());
|
||||||
|
modificationManager.create(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void processBatch(Long issueNumber, Map<String, Object> data) {
|
||||||
|
var list = (List<Map<String, Object>>) data.get("modifications");
|
||||||
|
if (list == null || list.isEmpty()) {
|
||||||
|
log.warn("Empty modifications list in issue #{}", issueNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var requests = list.stream()
|
||||||
|
.map(this::mapToRequest)
|
||||||
|
.toList();
|
||||||
|
log.info("Batch creating {} modifications from issue #{}", requests.size(), issueNumber);
|
||||||
|
modificationManager.batchCreate(requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModificationRequest mapToRequest(Map<String, Object> data) {
|
||||||
|
Long firearmId = modificationManager.resolveFirearmId(
|
||||||
|
toLong(data.get("firearmId")),
|
||||||
|
(String) data.get("firearmName"));
|
||||||
|
if (firearmId == null) {
|
||||||
|
throw new BizException(HttpStatus.BAD_REQUEST,
|
||||||
|
"YAML must contain firearmId or firearmName");
|
||||||
|
}
|
||||||
|
String name = (String) data.get("name");
|
||||||
|
String code = (String) data.get("code");
|
||||||
|
List<String> tags = toStringList(data.get("tags"));
|
||||||
|
List<AccessoryRequest> accessories = mapAccessories(data.get("accessories"));
|
||||||
|
String note = (String) data.get("note");
|
||||||
|
String author = (String) data.get("author");
|
||||||
|
String videoUrl = (String) data.get("videoUrl");
|
||||||
|
|
||||||
|
return new ModificationRequest(firearmId, name, code, tags, accessories,
|
||||||
|
note, author, videoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AccessoryRequest> mapAccessories(Object raw) {
|
||||||
|
if (!(raw instanceof List<?> list)) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
var result = new ArrayList<AccessoryRequest>();
|
||||||
|
for (var item : list) {
|
||||||
|
if (item instanceof Map<?, ?> map) {
|
||||||
|
result.add(new AccessoryRequest(
|
||||||
|
(String) map.get("slotName"),
|
||||||
|
(String) map.get("accessoryName"),
|
||||||
|
mapTunings(map.get("tunings"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TuningRequest> mapTunings(Object raw) {
|
||||||
|
if (!(raw instanceof List<?> list)) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
var result = new ArrayList<TuningRequest>();
|
||||||
|
for (var item : list) {
|
||||||
|
if (item instanceof Map<?, ?> map) {
|
||||||
|
result.add(new TuningRequest(
|
||||||
|
(String) map.get("tuningName"),
|
||||||
|
toDouble(map.get("tuningValue"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> toStringList(Object raw) {
|
||||||
|
if (raw instanceof List<?> list) {
|
||||||
|
return list.stream()
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(String.class::cast)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedSender(
|
||||||
|
GitHubWebhookSender sender
|
||||||
|
) {
|
||||||
|
var allowedUsers = webhookManager.github().allowedUsers();
|
||||||
|
if (allowedUsers == null || allowedUsers.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sender == null || sender.login() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return allowedUsers.contains(sender.login());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasTriggerLabel(List<GitHubWebhookLabel> labels) {
|
||||||
|
if (labels == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return labels.stream().anyMatch(label -> TRIGGER_LABEL.equals(label.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractYaml(String body) {
|
||||||
|
if (body == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var matcher = YAML_FENCE.matcher(body);
|
||||||
|
return matcher.find() ? matcher.group(1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long toLong(Object value) {
|
||||||
|
if (value instanceof Number num) {
|
||||||
|
return num.longValue();
|
||||||
|
}
|
||||||
|
if (value instanceof String s) {
|
||||||
|
return Long.parseLong(s);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double toDouble(Object value) {
|
||||||
|
if (value instanceof Number num) {
|
||||||
|
return num.doubleValue();
|
||||||
|
}
|
||||||
|
if (value instanceof String s) {
|
||||||
|
return Double.parseDouble(s);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.onixbyte.deltaforceguide.shared;
|
package com.onixbyte.deltaforceguide.shared;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for HTTP cookie names used for authentication tokens.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class CookieName {
|
public class CookieName {
|
||||||
|
|
||||||
public static final String ACCESS_TOKEN = "AccessToken";
|
public static final String ACCESS_TOKEN = "AccessToken";
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.onixbyte.deltaforceguide.shared;
|
package com.onixbyte.deltaforceguide.shared;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for supported authentication provider identifiers.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class CredentialProvider {
|
public class CredentialProvider {
|
||||||
|
|
||||||
public static final String LOCAL = "LOCAL";
|
public static final String LOCAL = "LOCAL";
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.shared;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class lists the header names that GitHub sends in webhook requests.
|
||||||
|
*
|
||||||
|
* @author siujamo
|
||||||
|
*/
|
||||||
|
public class GitHubWebhookHeader {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unique identifier of the webhook.
|
||||||
|
*/
|
||||||
|
public static final String HOOK_ID = "X-GitHub-Hook-ID";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the event that triggered the delivery.
|
||||||
|
*/
|
||||||
|
public static final String EVENT = "X-GitHub-Event";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A globally unique identifier (GUID) to identify the event.
|
||||||
|
*/
|
||||||
|
public static final String DELIVERY = "X-GitHub-Delivery";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This header is sent if the webhook is configured with a {@code secret}. This is the HMAC hex
|
||||||
|
* digest of the request body, and is generated using the SHA-1 hash function and the secret as
|
||||||
|
* the HMAC {@code key}. {@code X-Hub-Signature} is provided for compatibility with
|
||||||
|
* existing integrations. We recommend that you use the more secure
|
||||||
|
* {@code X-Hub-Signature-256} instead.
|
||||||
|
*/
|
||||||
|
public static final String SIGNATURE = "X-Hub-Signature";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This header is sent if the webhook is configured with a {@code secret}. This is the HMAC hex
|
||||||
|
* digest of the request body, and is generated using the SHA-256 hash function and the
|
||||||
|
* {@code secret} as the HMAC key. For more information, see <a href="https://docs.github.com/en/webhooks/using-webhooks/securing-your-webhooks"
|
||||||
|
* >Validating webhook deliveries</a>.
|
||||||
|
*/
|
||||||
|
public static final String SIGNATURE_256 = "X-Hub-Signature-256";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This header will always have the prefix {@code GitHub-Hookshot/}.
|
||||||
|
*/
|
||||||
|
public static final String USER_AGENT = "User-Agent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of resource where the webhook was created.
|
||||||
|
*/
|
||||||
|
public static final String INSTALLATION_TARGET_TYPE = "X-GitHub-Hook-Installation-Target-Type";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unique identifier of the resource where the webhook was created.
|
||||||
|
*/
|
||||||
|
public static final String INSTALLATION_TARGET_ID = "X-GitHub-Hook-Installation-Target-ID";
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Jackson {@link com.fasterxml.jackson.databind.Module} instances for custom date/time
|
||||||
|
* serialisation and deserialisation across the application.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class JacksonModules {
|
public class JacksonModules {
|
||||||
|
|
||||||
public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule();
|
public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule();
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import com.fasterxml.jackson.databind.SerializationFeature;
|
|||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton serialiser for Redis cache operations using
|
||||||
|
* {@link GenericJackson2JsonRedisSerializer} with JavaTime module support.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class JacksonRedisSerialiser {
|
public class JacksonRedisSerialiser {
|
||||||
|
|
||||||
public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer();
|
public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer();
|
||||||
|
|||||||
@@ -4,8 +4,19 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for date and time operations using system-default time zone.
|
||||||
|
*
|
||||||
|
* @author zihluwang
|
||||||
|
*/
|
||||||
public class DateTimeUtil {
|
public class DateTimeUtil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a {@link LocalDateTime} to an {@link Instant} using the system-default time zone.
|
||||||
|
*
|
||||||
|
* @param ldt the local date-time to convert
|
||||||
|
* @return the corresponding instant
|
||||||
|
*/
|
||||||
public static Instant asInstant(LocalDateTime ldt) {
|
public static Instant asInstant(LocalDateTime ldt) {
|
||||||
return ldt.atZone(ZoneId.systemDefault())
|
return ldt.atZone(ZoneId.systemDefault())
|
||||||
.toInstant();
|
.toInstant();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.wrapper;
|
||||||
|
|
||||||
|
import jakarta.servlet.ReadListener;
|
||||||
|
import jakarta.servlet.ServletInputStream;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||||
|
import org.springframework.util.StreamUtils;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public class RepeatedlyReadRequestWrapper extends HttpServletRequestWrapper {
|
||||||
|
|
||||||
|
private final byte[] body;
|
||||||
|
|
||||||
|
public RepeatedlyReadRequestWrapper(HttpServletRequest request) throws IOException {
|
||||||
|
super(request);
|
||||||
|
this.body = StreamUtils.copyToByteArray(request.getInputStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServletInputStream getInputStream() {
|
||||||
|
final var byteArrayInputStream = new ByteArrayInputStream(body);
|
||||||
|
return new ServletInputStream() {
|
||||||
|
@Override
|
||||||
|
public boolean isFinished() {
|
||||||
|
return byteArrayInputStream.available() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReady() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setReadListener(ReadListener readListener) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
return byteArrayInputStream.read();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedReader getReader() throws IOException {
|
||||||
|
return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBodyString() {
|
||||||
|
return new String(body, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ mybatis:
|
|||||||
type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler
|
type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler
|
||||||
mapper-locations: classpath:/mapper/*.xml
|
mapper-locations: classpath:/mapper/*.xml
|
||||||
|
|
||||||
|
app:
|
||||||
|
webhook:
|
||||||
|
github:
|
||||||
|
secret: ${GITHUB_WEBHOOK_SECRET:}
|
||||||
|
allowed-users: []
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
org.hibernate:
|
org.hibernate:
|
||||||
|
|||||||
Reference in New Issue
Block a user