Compare commits

...

23 Commits

Author SHA1 Message Date
siujamo 0aeaac17a4 feat: custom 401/403 error handling for unauthenticated requests
Replace default Spring Security 403 responses with custom 401 (not logged in)
and 403 (insufficient permissions) JSON error responses via exception handling
and global exception handler. Also bump API version to 1.4.0 and add Swagger
annotations for the GitHub webhook controller.
2026-06-29 09:38:12 +08:00
siujamo a2469c7573 docs: update application configuration example
Build and Deploy / build-and-release (release) Failing after 5m14s
2026-06-08 13:32:16 +08:00
siujamo 62fcce3005 feat: add OCI labels and README push for Docker Hub
Add OCI standard labels to Dockerfile.ci and push README.md to
Docker Hub image page via docker-pushrm in CI workflow.
2026-06-05 10:20:15 +08:00
siujamo c18a108a2b ci: fix build error
Build and Deploy / build-and-release (release) Successful in 14m20s
2026-06-05 09:40:12 +08:00
siujamo fb2732508a ci: fix build error
Build and Deploy / build-and-release (release) Failing after 11m24s
2026-06-05 09:30:28 +08:00
siujamo ceac8a3f8c ci: fix build error
Build and Deploy / build-and-release (release) Failing after 11m22s
2026-06-05 09:14:37 +08:00
siujamo f8c0e3e8b6 ci: fix build error
Build and Deploy / build-and-release (release) Failing after 11m36s
2026-06-05 08:58:55 +08:00
siujamo a065b60cae chore: migrate CI from GitHub Actions to Gitea Actions
Replace GitHub Actions workflow with Gitea Actions, and switch
container registry from GHCR to Docker Hub.
2026-06-04 17:36:43 +08:00
siujamo 17cd87c702 feat: inject build-time variables via Gradle processResources
Replace hardcoded AppProperties values with Gradle ${} placeholders,
allowing version/channel/vendor to be configured via gradle.properties
or -P flags at build time.

Also refactor webhook configuration to flatten the properties hierarchy
by removing the intermediate WebhookProperties wrapper.
2026-06-04 17:12:48 +08:00
siujamo 4e2da0debc feat: add expire time into login response 2026-06-04 14:47:45 +08:00
siujamo 0815d1d618 chore: optimise code style 2026-06-04 14:42:14 +08:00
zihluwang d323e4f8f7 chore: change traffic interceptor log level from info to debug 2026-06-02 01:46:49 +08:00
siujamo eb2d9b3369 fix: rename webhook filter bean to avoid name collision with WebhookFilter class 2026-06-01 17:18:42 +08:00
siujamo a0d54cc12d Merge pull request #2 from onixbyte/feature/github-webhook
feat: GitHub webhook — create modifications from labelled issues
2026-06-01 17:04:21 +08:00
siujamo 4eafb3ade7 Merge branch 'develop' into feature/github-webhook 2026-06-01 17:03:48 +08:00
siujamo 9594efe716 fix: use BizException instead of ResponseStatusException for firearmId validation 2026-06-01 17:01:50 +08:00
siujamo 5b5062aae9 fix: validate firearmId is present before constructing ModificationRequest 2026-06-01 17:00:03 +08:00
siujamo b0c41e08ea fix: use \R instead of \n in YAML fence regex for cross-platform linebreaks 2026-06-01 16:59:47 +08:00
siujamo ed2a0f4ae0 style: replace fully qualified type names and clean up imports 2026-06-01 16:59:40 +08:00
siujamo de61e1feb7 refactor: remove redundant @Transactional from delegated service methods 2026-06-01 16:59:12 +08:00
siujamo 3616ad9eab chore: remove unused imports in GitHubWebhookInterceptor 2026-06-01 16:59:08 +08:00
zihluwang bd4fe65b03 Merge remote-tracking branch 'origin/develop' into develop 2026-05-29 00:42:34 +08:00
zihluwang eb22b3c4bb docs: add Javadocs 2026-05-29 00:42:27 +08:00
86 changed files with 910 additions and 219 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: delta-force-guide-server
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21 (Corretto)
uses: actions/setup-java@v4
with:
java-version: 21
distribution: corretto
cache: gradle
- name: Build with Gradle
run: >
./gradlew bootJar -x test
-PartefactVersion="${{ gitea.event.release.tag_name }}"
-PbuildChannel=stable
-Pvendor=${{ vars.VENDOR }}
- name: Resolve JAR file path
id: jar
run: |
JAR_PATH=$(find build/libs -name '*.jar' | head -1)
echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT"
- name: Upload JAR to Gitea Release
run: |
TAG="${{ gitea.event.release.tag_name }}"
FILE="${{ steps.jar.outputs.file }}"
ASSET_NAME="${APP_NAME}-${TAG}.jar"
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@${FILE};filename=${ASSET_NAME}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${{ gitea.event.release.id }}/assets?name=${ASSET_NAME}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate image tags
id: meta
run: |
DOCKERHUB_USER="${{ vars.DOCKER_HUB_USERNAME }}"
echo "tag_version=${DOCKERHUB_USER}/${APP_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "tag_latest=${DOCKERHUB_USER}/${APP_NAME}:latest" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.ci
build-args: |
JAR_FILE=${{ steps.jar.outputs.file }}
IMAGE_VERSION=${{ gitea.event.release.tag_name }}
IMAGE_VENDOR=${{ vars.VENDOR }}
BUILD_DATE=${{ gitea.event.release.published_at }}
push: true
tags: |
${{ steps.meta.outputs.tag_version }}
${{ steps.meta.outputs.tag_latest }}
- name: Push README to Docker Hub
run: >
docker run --rm
-v "$(pwd)/README.md:/workspace/README.md:ro"
-e DOCKER_USER="${{ vars.DOCKER_HUB_USERNAME }}"
-e DOCKER_PASS="${{ secrets.DOCKER_HUB_TOKEN }}"
chko/docker-pushrm:latest
"${{ vars.DOCKER_HUB_USERNAME }}/${{ env.APP_NAME }}"
-86
View File
@@ -1,86 +0,0 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: delta-force-guide-server
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ================================================================
# Single Job: Build, Upload JAR to Release, and Push to GHCR
# ================================================================
build-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21 (Corretto)
uses: actions/setup-java@v4
with:
java-version: 21
distribution: corretto
cache: gradle
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
# 使用 Release Tag 做为 Gradle 属性传入
- name: Build with Gradle
run: ./gradlew bootJar -x test -PartefactVersion="${{ github.event.release.tag_name }}"
- name: Resolve JAR file path
id: jar
run: |
JAR_PATH=$(find build/libs -name '*.jar' | head -1)
echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT"
# 上传 JAR 包到 GitHub Release 中
- name: Upload JAR to GitHub Release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.jar.outputs.file }}
asset_name: ${{ github.event.repository.name }}-${{ github.event.release.tag_name }}.jar
tag: ${{ github.event.release.tag_name }}
overwrite: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 登录到 GitHub Container Registry (GHCR)
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 镜像打标签准备
- name: Generate image tags
id: meta
run: |
OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
REPO_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
echo "tag_version=ghcr.io/$OWNER_LC/$REPO_LC:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "tag_latest=ghcr.io/$OWNER_LC/$REPO_LC:latest" >> "$GITHUB_OUTPUT"
# 构建并上传镜像到 GHCR
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.ci
build-args: JAR_FILE=${{ steps.jar.outputs.file }}
push: true
tags: |
${{ steps.meta.outputs.tag_version }}
${{ steps.meta.outputs.tag_latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
-34
View File
@@ -1,34 +0,0 @@
variables:
GRADLE_OPTS: -Dorg.gradle.daemon=false
DOCKER_HOST: unix:///var/run/docker.sock
stages:
- release
release:
stage: release
image: amazoncorretto:21-alpine
cache:
key: gradle
paths:
- .gradle/wrapper
- .gradle/caches
before_script:
- chmod +x gradlew
- apk add --no-cache docker-cli
script:
- ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG"
- JAR_FILE=$(find build/libs -name '*.jar' | head -1)
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
- docker build
-f Dockerfile.ci
--build-arg JAR_FILE="$JAR_FILE"
-t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
.
- docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest"
- echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
+12
View File
@@ -2,6 +2,18 @@ FROM amazoncorretto:21-alpine
WORKDIR /app
ARG JAR_FILE
ARG IMAGE_VERSION=dev
ARG IMAGE_VENDOR=OnixByte
ARG BUILD_DATE
COPY ${JAR_FILE} app.jar
LABEL org.opencontainers.image.title="delta-force-guide-server" \
org.opencontainers.image.description="REST API backend for managing Delta Force game firearm builds and modifications" \
org.opencontainers.image.version="${IMAGE_VERSION}" \
org.opencontainers.image.vendor="${IMAGE_VENDOR}" \
org.opencontainers.image.source="https://git.onixbyte.com/onixbyte/delta-force-guide-server" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.created="${BUILD_DATE}"
ENTRYPOINT ["java", "-jar", "app.jar"]
+13
View File
@@ -5,6 +5,8 @@ plugins {
}
val artefactVersion: String by project
val buildChannel: String by project
val vendor: String by project
group = "com.onixbyte.helix"
version = artefactVersion
@@ -61,6 +63,17 @@ dependencies {
testRuntimeOnly(libs.junit.launcher)
}
tasks.processResources {
filesMatching("application.yaml") {
println("appVersion = ${artefactVersion}, channel = ${buildChannel}, vendor = ${vendor}")
expand(
"appVersion" to artefactVersion,
"channel" to buildChannel,
"vendor" to vendor
)
}
}
tasks.test {
useJUnitPlatform()
}
+11 -9
View File
@@ -9,15 +9,19 @@ spring:
host: localhost
port: 6379
database: 0
# password: 6hLFVqfGPviTYukn # Uncomment if password is necessary
# password: qwerty # Uncomment if password is necessary
logging:
pattern:
# dateformat: dd MMM yyyy HH:mm:ss.SSS # Modify this for custom date format.
app:
common:
version: 1.3.0.8-dev # Application version, you can change to any version you like, used for communication with frontend.
cookie:
http-only: true
secure: false
same-site: none
path: '/'
max-age: P1D
cors:
allowed-origins: # Cross-origin allowed origins
- "http://localhost:5173" # Dev server for vite.
@@ -41,12 +45,10 @@ app:
issuer: dfguide.local # Issuer host
secret: qwertyuiopasdfghjklzxcvbnm123456 # JWT singing secret, a 32-byte long or longer string is recommended
valid-time: PT2H # JWT valid duration
cookie: # Cookie settings.
http-only: true
secure: false
same-site: lax
path: '/'
max-age: PT2H
webhook:
github:
secret: 123456
allowed-users: [ octotcat, onixbyte ]
springdoc:
api-docs:
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Entry point for the Delta Force Guide Server application.
*
* @author zihluwang
*/
@SpringBootApplication
public class DeltaForceGuideApplication {
@@ -12,6 +12,11 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* Client for generating and verifying JSON Web Tokens using the Auth0 java-jwt library.
*
* @author zihluwang
*/
@Component
public class TokenClient {
@@ -12,10 +12,21 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
import java.util.stream.Stream;
/**
* Configuration for CORS (Cross-Origin Resource Sharing) policies.
*
* @author zihluwang
*/
@Configuration
@EnableConfigurationProperties({CorsProperties.class})
public class CorsConfig {
/**
* Creates the CORS configuration source with properties from configuration.
*
* @param properties the CORS configuration properties
* @return the CORS configuration source
*/
@Bean
public CorsConfigurationSource corsConfigurationSource(
CorsProperties properties
@@ -9,7 +9,7 @@ import org.springframework.context.annotation.Configuration;
public class FilterConfig {
@Bean
public FilterRegistrationBean<WebhookFilter> webhookFilter(WebhookFilter webhookFilter) {
public FilterRegistrationBean<WebhookFilter> webhookFilterBean(WebhookFilter webhookFilter) {
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
registrationBean.setFilter(webhookFilter);
@@ -6,6 +6,11 @@ import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilde
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for Jackson JSON serialisation and deserialisation settings.
*
* @author zihluwang
*/
@Configuration
public class JacksonConfig {
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for MyBatis SQL mapping framework integration.
*
* @author zihluwang
*/
@Configuration
@MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"})
public class MyBatisConfig {
@@ -10,14 +10,14 @@ import org.springframework.context.annotation.Configuration;
info = @Info(
title = "Delta Force Guide Server",
description = "API for managing Delta Force game firearm builds",
version = "1.3.4",
version = "1.4.0",
contact = @Contact(
name = "Zihlu Wang",
email = "zihlu.wang@onixbyte.com"
),
license = @License(
name = "MIT",
url = "https://git.onixbyte.cn/onixbyte/delta-force-guide-server/-/raw/main/LICENCE"
url = "https://onixbyte.dev/onixbyte/delta-force-guide-server/raw/branch/main/LICENCE"
)
)
)
@@ -7,6 +7,8 @@ import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter;
import com.onixbyte.deltaforceguide.properties.CookieProperties;
import com.onixbyte.deltaforceguide.properties.TokenProperties;
import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -19,16 +21,31 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.onixbyte.deltaforceguide.exeption.BizException;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
/**
* Spring Security configuration defining authentication, authorisation, and filter chains.
*
* @author zihluwang
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
/**
* Configures the HTTP security filter chain including endpoint authorisation and JWT filter.
*
* @param http the HTTP security builder
* @return the configured security filter chain
*/
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity,
@@ -44,15 +61,34 @@ public class SecurityConfig {
.authorizeHttpRequests((customiser) -> customiser
.anyRequest().permitAll()
)
.exceptionHandling(customiser -> customiser
.authenticationEntryPoint((request, response, authException) -> {
log.error("Unauthenticated request: {}", request, authException);
throw new BizException(HttpStatus.UNAUTHORIZED, "请先登录");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
log.error("Denied request: {}", request, accessDeniedException);
throw new BizException(HttpStatus.FORBIDDEN, "权限不足");
})
)
.addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class)
.build();
}
/**
* Provides the BCrypt password encoder for credential hashing.
* @return the BCrypt password encoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Provides the authentication manager for the security configuration.
*
* @return the authentication manager
*/
@Bean
public AuthenticationManager authenticationManager(
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Configuration for Spring Data JPA auditing and repository settings.
*
* @author zihluwang
*/
@Configuration
@EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"})
public class SpringDataConfig {
@@ -1,10 +1,10 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(WebhookProperties.class)
@EnableConfigurationProperties({GitHubWebhookProperties.class})
public class WebhookConfig {
}
@@ -18,7 +18,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* REST controller for user authentication endpoints (login, logout).
*
* @author zihluwang
*/
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
@RestController
@RequestMapping("/auth")
@@ -38,12 +44,14 @@ public class AuthController {
@PostMapping("/login")
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
var user = authService.login(request);
var currentTime = LocalDateTime.now();
var accessToken = tokenClient.generateToken(user);
var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken);
var cookieMaxAge = accessTokenCookie.getMaxAge();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
.body(UserResponse.from(user));
.body(UserResponse.from(user, currentTime.plus(cookieMaxAge)));
}
@RequiresAuth
@@ -8,6 +8,11 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for retrieving daily-generated passwords.
*
* @author zihluwang
*/
@Tag(name = "每日密码", description = "获取每日密码信息")
@RestController
@RequestMapping("/daily-passwords")
@@ -15,6 +15,11 @@ import org.springframework.data.domain.Sort;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* REST controller for firearm CRUD operations.
*
* @author zihluwang
*/
@Tag(name = "武器管理", description = "与武器有关的操作")
@RestController
@RequestMapping("/firearms")
@@ -3,6 +3,8 @@ package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.deltaforceguide.service.WebhookService;
import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "GitHub WebHook")
@RestController
@RequestMapping("/webhooks/github")
public class GitHubWebhookController {
@@ -24,6 +27,7 @@ public class GitHubWebhookController {
this.webhookService = webhookService;
}
@Operation(description = "GitHub WebHook 接口")
@PostMapping
public ResponseEntity<Void> handleWebhook(
@RequestHeader(GitHubWebhookHeader.EVENT) String event,
@@ -2,10 +2,19 @@ package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.ErrorResponse;
import com.onixbyte.deltaforceguide.exeption.BizException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Global exception handler that translates exceptions into standard error responses.
*
* @author zihluwang
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@@ -15,5 +24,22 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status)
.body(new ErrorResponse(exception.getMessage()));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("请先登录"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException exception) {
var authentication = SecurityContextHolder.getContext().getAuthentication();
var httpStatus = authentication == null || !authentication.isAuthenticated()
? HttpStatus.UNAUTHORIZED
: HttpStatus.FORBIDDEN;
var message = httpStatus == HttpStatus.UNAUTHORIZED ? "请先登录" : "权限不足";
return ResponseEntity.status(httpStatus)
.body(new ErrorResponse(message));
}
}
@@ -27,6 +27,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* REST controller for modification CRUD operations, including batch creation and deletion.
*
* @author zihluwang
*/
@Tag(name = "改装管理", description = "对枪械改装的管理")
@RestController
@RequestMapping("/modifications")
@@ -10,6 +10,11 @@ import com.onixbyte.deltaforceguide.service.ModificationService;
import java.util.List;
/**
* REST controller for retrieving available modification tags.
*
* @author zihluwang
*/
@Tag(name = "标签管理", description = "管理标签信息")
@RestController
@RequestMapping("/tags")
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* JPA attribute converter that maps {@link FirearmType} enum to/from its integer database representation.
*
* @author zihluwang
*/
@Converter
public class FirearmTypeConverter implements AttributeConverter<FirearmType, Integer> {
@@ -6,6 +6,11 @@ import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;
/**
* Request DTO for creating or updating an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryRequest(
@NotBlank(message = "插槽名称不能为空")
String slotName,
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import java.util.List;
/**
* Response DTO for an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryResponse(
String slotName,
String accessoryName,
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto;
/**
* DTO representing a single daily-generated password for a map.
*
* @author zihluwang
*/
public record DailyPassword(
String mapName,
String password
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import java.time.LocalDateTime;
import java.util.List;
/**
* DTO containing daily password data including update information and password list.
*
* @author zihluwang
*/
public record DailyPasswordData(
String updateDate,
Integer totalCount,
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto;
/**
* DTO holding metadata about the daily password source and update tracking.
*
* @author zihluwang
*/
public record DailyPasswordMetadata(
String version,
String author
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto;
/**
* Response DTO wrapping daily password data with metadata.
*
* @author zihluwang
*/
public record DailyPasswordResponse(
String status,
String message,
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto;
/**
* Standard error response body returned on API failures.
*
* @author zihluwang
*/
public record ErrorResponse(
String message
) {
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
/**
* Request DTO for creating or updating a firearm.
*
* @author zihluwang
*/
public record FirearmRequest(
String name,
FirearmType type,
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.enumeration.FirearmType;
/**
* Response DTO for a firearm record, including associated modifications.
*
* @author zihluwang
*/
public record FirearmResponse(
Long id,
String name,
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
/**
* Login request containing principle (username/email) and credential (password).
*
* @author zihluwang
*/
@Schema(description = "登录请求")
public record LoginRequest(
@NotBlank(message = "登录名称不能为空")
@@ -5,6 +5,11 @@ import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* Request DTO for batch creation of modifications.
*
* @author zihluwang
*/
public record ModificationBatchCreateRequest(
@NotEmpty(message = "批量创建列表不能为空")
List<@Valid ModificationRequest> modifications
@@ -5,6 +5,11 @@ import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* Request DTO for batch deletion of modifications by ID.
*
* @author zihluwang
*/
public record ModificationBatchDeleteRequest(
@NotEmpty(message = "批量删除ID列表不能为空")
List<@Positive(message = "ID必须为正数") Long> ids
@@ -8,6 +8,11 @@ import jakarta.validation.constraints.Positive;
import java.util.ArrayList;
import java.util.List;
/**
* Request DTO for creating or updating a modification.
*
* @author zihluwang
*/
public record ModificationRequest(
@NotNull(message = "武器ID不能为空")
@Positive(message = "武器ID必须为正数")
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification;
import java.util.List;
/**
* Response DTO for a modification record including accessories and tags.
*
* @author zihluwang
*/
public record ModificationResponse(
Long id,
Long firearmId,
@@ -4,6 +4,11 @@ import org.springframework.data.domain.Page;
import java.util.List;
/**
* Generic paginated response wrapper for list endpoints.
*
* @author zihluwang
*/
public record PageResponse<T>(
List<T> items,
int page,
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningRequest(
@NotBlank(message = "调校项名称不能为空")
String tuningName,
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
/**
* Response DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningResponse(
String tuningName,
Double tuningValue
@@ -2,16 +2,25 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.User;
import java.time.LocalDateTime;
/**
* Response DTO for a user account, including associated credentials.
*
* @author zihluwang
*/
public record UserResponse(
Long id,
String username,
String email
String email,
LocalDateTime expiration
) {
public static UserResponse from(User user) {
public static UserResponse from(User user, LocalDateTime expiration) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail()
user.getEmail(),
expiration
);
}
}
@@ -4,6 +4,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Entity representing an accessory attached to a modification, stored as JSONB.
*
* @author zihluwang
*/
public class Accessory {
private String slotName;
@@ -15,6 +15,11 @@ import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
/**
* Entity representing a firearm in the Delta Force game.
*
* @author zihluwang
*/
@Entity
@Table(name = "firearm")
public class Firearm {
@@ -7,6 +7,11 @@ import org.hibernate.annotations.Type;
import java.util.ArrayList;
import java.util.List;
/**
* Entity representing a firearm modification or build configuration.
*
* @author zihluwang
*/
@Entity
@Table(
name = "modification",
@@ -2,6 +2,11 @@ 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 {
private String tuningName;
@@ -12,6 +12,11 @@ import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
/**
* Entity representing an application user with authentication credentials.
*
* @author zihluwang
*/
@Entity
@Table(name = "app_user")
public class User {
@@ -12,6 +12,11 @@ import jakarta.persistence.MapsId;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
/**
* Entity representing a user credential linked to an authentication provider.
*
* @author zihluwang
*/
@Entity
@Table(name = "app_user_credential")
public class UserCredential {
@@ -28,7 +33,7 @@ public class UserCredential {
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user"))
private User user;
@Column(name = "credential", nullable = false, length = 255)
@Column(name = "credential", nullable = false)
private String credential;
public UserCredentialId getId() {
@@ -5,6 +5,11 @@ import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
/**
* Composite key for the UserCredential entity, combining user ID and provider.
*
* @author zihluwang
*/
@Embeddable
public class UserCredentialId implements Serializable {
@@ -1,5 +1,11 @@
package com.onixbyte.deltaforceguide.enumeration;
/**
* Enumeration of firearm types in the Delta Force game.
* Each type is associated with an integer code used for database persistence.
*
* @author zihluwang
*/
public enum FirearmType {
RIFLE(0),
@@ -21,6 +27,13 @@ public enum FirearmType {
return code;
}
/**
* Resolve a FirearmType from its integer code.
*
* @param code the integer code, may be null
* @return the corresponding FirearmType, or null if the code is null
* @throws IllegalArgumentException if the code does not match any known type
*/
public static FirearmType fromCode(Integer code) {
if (code == null) {
return null;
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.exeption;
import org.springframework.http.HttpStatus;
/**
* Custom runtime exception that carries an HTTP status code for API error responses.
*
* @author zihluwang
*/
public class BizException extends RuntimeException {
/**
@@ -16,7 +16,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -29,6 +28,11 @@ import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request.
*
* @author zihluwang
*/
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@@ -52,6 +56,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
this.handlerExceptionResolver = handlerExceptionResolver;
}
/**
* Extracts JWT from httpOnly cookie, validates it, and sets the security context.
*
* @param request the HTTP request
* @param response the HTTP response
* @param filterChain the filter chain
*/
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@@ -14,8 +14,6 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
@@ -50,7 +48,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor {
"Request body is not readable");
}
var secret = webhookManager.github().secret();
var secret = webhookManager.secret();
if (secret == null || secret.isBlank()) {
log.debug("No GitHub webhook secret configured, skipping signature verification");
return true;
@@ -66,7 +64,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor {
var body = req.getBodyString();
try {
var computed = "sha256=" + CryptoUtil.hmacSha256(secret, body);
var computed = "sha256=" + CryptoUtil.hmacSha256(body, secret);
if (!MessageDigest.isEqual(
computed.getBytes(StandardCharsets.UTF_8),
@@ -27,7 +27,7 @@ public class TrafficInterceptor implements HandlerInterceptor {
var contentLength = request.getContentLength();
var userAgent = request.getHeader("User-Agent");
log.info("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}",
log.debug("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}",
method, uri, query, ip, contentType, contentLength, userAgent);
return true;
}
@@ -18,6 +18,10 @@ public class AppManager {
* @return the version string of this application
*/
public String getVersion() {
return appProperties.version();
return "v%s-%s by @%s".formatted(
appProperties.version(),
appProperties.channel(),
appProperties.vendor()
);
}
}
@@ -6,6 +6,11 @@ import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* Manager providing cookie construction operations with configurable properties.
*
* @author zihluwang
*/
@Component
public class CookieManager {
@@ -17,6 +17,11 @@ import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
/**
* Manager for daily password data access and caching coordination.
*
* @author zihluwang
*/
@Component
public class DailyPasswordManager {
@@ -49,6 +54,10 @@ public class DailyPasswordManager {
this.redisTemplate = redisTemplate;
}
/**
* Retrieves the daily password from cache or generates a new one.
* @return the daily password response
*/
public DailyPasswordResponse getDailyPassword() {
var key = CACHE_KEY_PREFIX + LocalDate.now();
@@ -3,11 +3,15 @@ package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.repository.UserCredentialRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* Manager for user credential persistence and authentication data access.
*
* @author zihluwang
*/
@Component
public class UserCredentialManager {
@@ -17,27 +21,52 @@ public class UserCredentialManager {
this.userCredentialRepository = userCredentialRepository;
}
@Transactional(readOnly = true)
/**
* Finds all credentials belonging to a specific user.
*
* @param userId the user ID
* @return list of matching credentials
*/
public List<UserCredential> findAllByUserId(Long userId) {
return userCredentialRepository.findAllByUserId(userId);
}
@Transactional(readOnly = true)
/**
* Finds a credential for a specific user and provider combination.
*
* @param userId the user ID
* @param provider the authentication provider
* @return the matching credential, if found
*/
public Optional<UserCredential> findByUserIdAndProvider(Long userId, String provider) {
return userCredentialRepository.findByUserIdAndProvider(userId, provider);
}
@Transactional
/**
* Persists a new or updated credential.
*
* @param userCredential the credential to save
* @return the saved credential
*/
public UserCredential save(UserCredential userCredential) {
return userCredentialRepository.save(userCredential);
}
@Transactional
/**
* Deletes a credential for a specific user and provider.
*
* @param userId the user ID
* @param provider the authentication provider
*/
public void deleteByUserIdAndProvider(Long userId, String provider) {
userCredentialRepository.deleteByUserIdAndProvider(userId, provider);
}
@Transactional
/**
* Deletes all credentials belonging to a user.
*
* @param userId the user ID
*/
public void deleteAllByUserId(Long userId) {
userCredentialRepository.deleteAllByUserId(userId);
}
@@ -8,6 +8,11 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* Manager for user entity persistence and query operations.
*
* @author zihluwang
*/
@Component
public class UserManager {
@@ -17,17 +22,30 @@ public class UserManager {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
/**
* Finds a user by their ID.
*
* @param id the user ID
* @return the matching user, if found
*/
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
@Transactional(readOnly = true)
/**
* Retrieves all registered users.
* @return list of all users
*/
public List<User> 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) {
return userRepository.findByUsername(username);
}
@@ -37,16 +55,31 @@ public class UserManager {
return userRepository.findByEmail(email);
}
@Transactional
/**
* Persists a new or updated user.
*
* @param user the user to save
* @return the saved user
*/
public User save(User user) {
return userRepository.save(user);
}
@Transactional
/**
* Deletes a user by their ID.
*
* @param id the user ID to delete
*/
public void deleteById(Long id) {
userRepository.deleteById(id);
}
/**
* Finds a user by their username or email address.
*
* @param principal the username or email to search for
* @return the matching user, if found
*/
public Optional<User> findByUsernameOrEmail(String principal) {
return userRepository.findByUsernameOrEmail(principal);
}
@@ -1,21 +1,24 @@
package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties;
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class WebhookManager {
private final WebhookProperties webhookProperties;
private final GitHubWebhookProperties gitHubWebhookProperties;
@Autowired
public WebhookManager(WebhookProperties webhookProperties) {
this.webhookProperties = webhookProperties;
public WebhookManager(GitHubWebhookProperties gitHubWebhookProperties) {
this.gitHubWebhookProperties = gitHubWebhookProperties;
}
public GitHubWebhookProperties github() {
return webhookProperties.github();
public String secret() {
return gitHubWebhookProperties.secret();
}
public List<String> allowedUsers() {
return gitHubWebhookProperties.allowedUsers();
}
}
@@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.common")
public record AppProperties(
String version
String version,
String channel,
String vendor
) {
}
@@ -6,6 +6,16 @@ import org.springframework.boot.web.server.Cookie;
import java.time.Duration;
/**
* Configuration properties for HTTP cookies used in authentication, prefixed with "app.cookie".
*
* @param httpOnly whether the cookie is httpOnly
* @param secure whether the cookie is secure
* @param path the cookie path
* @param maxAge the maximum age of the cookie
* @param sameSite the SameSite policy for the cookie
* @author zihluwang
*/
@ConfigurationProperties(prefix = "app.cookie")
public record CookieProperties(
@DefaultValue("true") Boolean httpOnly,
@@ -6,6 +6,18 @@ import org.springframework.http.HttpMethod;
import java.time.Duration;
/**
* Configuration properties for CORS settings, prefixed with "app.cors".
*
* @param allowedHeaders headers allowed in CORS requests
* @param allowedMethods HTTP methods allowed in CORS requests
* @param allowedOrigins origins permitted to make cross-origin requests
* @param allowCredentials whether credentials are allowed in CORS requests
* @param allowPrivateNetwork whether private network access is permitted
* @param maxAge how long the CORS preflight response may be cached
* @param exposedHeaders headers exposed to the client in CORS responses
* @author zihluwang
*/
@ConfigurationProperties(prefix = "app.cors")
public record CorsProperties(
@DefaultValue({"Content-Type", "Authorization"})
@@ -1,7 +1,10 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
@ConfigurationProperties(prefix = "app.webhook.github")
public record GitHubWebhookProperties(
String secret,
List<String> allowedUsers
@@ -4,6 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Configuration properties for JWT token generation and validation, prefixed with "app.jwt".
*
* @param issuer the JWT issuer claim
* @param secret the signing secret for JWT tokens
* @param validTime the duration for which a token remains valid
* @author zihluwang
*/
@ConfigurationProperties(prefix = "app.jwt")
public record TokenProperties(
String issuer,
@@ -1,9 +0,0 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.webhook")
public record WebhookProperties(
GitHubWebhookProperties github
) {
}
@@ -9,6 +9,11 @@ import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA repository for {@link Firearm} entity operations.
*
* @author zihluwang
*/
@Repository
public interface FirearmRepository extends JpaRepository<Firearm, Long> {
@@ -13,6 +13,12 @@ import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Spring Data JPA repository for {@link Modification} entity operations,
* including native JSONB tag filtering for Postgres.
*
* @author zihluwang
*/
@Repository
public interface ModificationRepository extends JpaRepository<Modification, Long> {
@@ -27,6 +33,14 @@ public interface ModificationRepository extends JpaRepository<Modification, Long
@NonNull
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 = """
SELECT * FROM modification m
WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId)
@@ -40,6 +54,12 @@ public interface ModificationRepository extends JpaRepository<Modification, Long
nativeQuery = true)
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)
List<String> findAllTags(@Param("firearmId") Long firearmId);
}
@@ -12,9 +12,20 @@ import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Spring Data JPA repository for {@link UserCredential} entity operations.
*
* @author zihluwang
*/
@Repository
public interface UserCredentialRepository extends JpaRepository<UserCredential, UserCredentialId> {
/**
* Find all credentials belonging to a given user.
*
* @param userId the user ID
* @return list of matching credentials
*/
@EntityGraph(attributePaths = {"user"})
@Query("""
select uc
@@ -23,6 +34,13 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
""")
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"})
@Query("""
select uc
@@ -32,6 +50,12 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
""")
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
@Query("""
delete from UserCredential uc
@@ -40,6 +64,11 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
""")
void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
/**
* Delete all credentials for a given user.
*
* @param userId the user ID
*/
@Modifying
@Query("""
delete from UserCredential uc
@@ -10,6 +10,11 @@ import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Spring Data JPA repository for {@link User} entity operations.
*
* @author zihluwang
*/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@@ -28,6 +33,12 @@ public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
/**
* Find a user by either username or email.
*
* @param principal the username or email to search for
* @return an optional containing the matching user, or empty if not found
*/
@EntityGraph(attributePaths = {"credentials"})
@Query("""
select u
@@ -7,6 +7,11 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation to mark controller endpoints that require authentication.
*
* @author zihluwang
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated()")
@@ -8,6 +8,11 @@ import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
import java.util.List;
/**
* Custom authentication token for username/password-based login flows.
*
* @author zihluwang
*/
public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer {
private final String username;
private String password;
@@ -17,6 +17,11 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Authentication provider that validates username/password credentials against stored BCrypt hashes.
*
* @author zihluwang
*/
@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
@@ -36,6 +41,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro
this.userCredentialRepository = userCredentialRepository;
}
/**
* Validates the username/password credentials against stored BCrypt hashes.
*
* @param authentication the authentication request object
* @return a fully authenticated object including user details
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) {
@@ -75,6 +86,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro
return usernamePasswordAuthentication;
}
/**
* Checks if this provider supports the given authentication type.
*
* @param authentication the authentication class to check
* @return true if this provider supports the given type
*/
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthentication.class.isAssignableFrom(authentication);
@@ -10,9 +10,11 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
/**
* Service handling user authentication, login, and session management.
*
* @author zihluwang
*/
@Service
public class AuthService {
@@ -23,6 +25,16 @@ public class AuthService {
this.authenticationManager = authenticationManager;
}
/**
* Authenticates a user with the given login credentials.
* <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) {
var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication
.unauthenticated(request.principle(), request.credential()));
@@ -6,6 +6,11 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* Service for building HTTP cookies with configurable properties.
*
* @author zihluwang
*/
@Service
public class CookieService {
@@ -15,10 +20,25 @@ public class CookieService {
this.cookieManager = cookieManager;
}
/**
* Builds a response cookie with the default max age from configuration.
*
* @param cookieName the cookie name
* @param value the cookie value
* @return a configured ResponseCookie
*/
public ResponseCookie buildCookie(String cookieName, String value) {
return buildCookieInternal(cookieName, value, cookieManager.getMaxAge());
}
/**
* Builds a response cookie with a custom valid duration.
*
* @param cookieName the cookie name
* @param value the cookie value
* @param validDuration the cookie's max age
* @return a configured ResponseCookie
*/
public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) {
return buildCookieInternal(cookieName, value, validDuration);
}
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.deltaforceguide.manager.DailyPasswordManager;
import org.springframework.stereotype.Service;
/**
* Service for generating and caching daily rotation passwords.
*
* @author zihluwang
*/
@Service
public class DailyPasswordService {
@@ -13,6 +18,10 @@ public class DailyPasswordService {
this.dailyPasswordManager = dailyPasswordManager;
}
/**
* Retrieves the daily password for the current day.
* @return the daily password response
*/
public DailyPasswordResponse getDailyPassword() {
return dailyPasswordManager.getDailyPassword();
}
@@ -11,9 +11,13 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
/**
* Service handling firearm business logic including CRUD operations and queries.
*
* @author zihluwang
*/
@Service
public class FirearmService {
@@ -23,7 +27,13 @@ public class FirearmService {
this.firearmRepository = firearmRepository;
}
@Transactional(readOnly = true)
/**
* Queries firearms with optional type filter and pagination.
*
* @param type optional firearm type filter
* @param pageable pagination parameters
* @return a paginated response of firearm records
*/
public PageResponse<FirearmResponse> pageQuery(FirearmType type, Pageable pageable) {
Page<Firearm> page = type == null
? firearmRepository.findAll(pageable)
@@ -32,13 +42,24 @@ public class FirearmService {
return PageResponse.from(page.map(FirearmResponse::from));
}
@Transactional(readOnly = true)
/**
* Finds a firearm by its ID.
*
* @param id the firearm ID
* @return the firearm response
*/
public FirearmResponse queryById(Long id) {
return firearmRepository.findById(id)
.map(FirearmResponse::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
}
/**
* Creates a new firearm from the provided request data.
*
* @param request the firearm creation request
* @return the created firearm response
*/
public FirearmResponse addFirearm(FirearmRequest request) {
var firearm = firearmRepository.save(Firearm.builder()
.name(request.name())
@@ -54,7 +75,13 @@ public class FirearmService {
return FirearmResponse.from(firearm);
}
@Transactional
/**
* Updates an existing firearm identified by ID.
*
* @param id the firearm ID
* @param request the updated firearm data
* @return the updated firearm response
*/
public FirearmResponse updateFirearm(Long id, FirearmRequest request) {
var firearm = firearmRepository.findById(id)
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
@@ -71,7 +98,11 @@ public class FirearmService {
return FirearmResponse.from(firearmRepository.save(firearm));
}
@Transactional
/**
* Deletes a firearm by its ID.
*
* @param id the firearm ID to delete
*/
public void deleteFirearm(Long id) {
Firearm firearm = firearmRepository.findById(id)
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
@@ -18,7 +18,6 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
@@ -26,6 +25,11 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Service handling modification business logic including CRUD, batch operations, and tag filtering.
*
* @author zihluwang
*/
@Service
public class ModificationService {
@@ -46,7 +50,14 @@ public class ModificationService {
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
/**
* Queries modifications with optional firearm and tag filters.
*
* @param firearmId optional firearm ID filter
* @param tags optional tag list filter
* @param pageable pagination parameters
* @return a paginated response of modification records
*/
public PageResponse<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) {
String tagsJson = null;
if (tags != null && !tags.isEmpty()) {
@@ -67,29 +78,55 @@ public class ModificationService {
return PageResponse.from(page.map(ModificationResponse::from));
}
@Transactional(readOnly = true)
/**
* Finds a modification by its ID.
*
* @param id the modification ID
* @return the modification response
*/
public ModificationResponse queryById(Long id) {
return modificationRepository.findById(id)
.map(ModificationResponse::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
}
@Transactional(readOnly = true)
/**
* Finds all unique tags across modifications, optionally scoped to a firearm.
*
* @param firearmId optional firearm ID to scope the tag search
* @return list of unique tag strings
*/
public List<String> findAllTags(Long firearmId) {
return modificationRepository.findAllTags(firearmId);
}
@Transactional
/**
* Creates a new modification for a given firearm.
*
* @param request the modification creation request
* @return the created modification response
*/
public ModificationResponse create(ModificationRequest request) {
return modificationManager.create(request);
}
@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) {
return modificationManager.batchCreate(requests);
}
@Transactional
/**
* Updates an existing modification identified by ID.
*
* @param id the modification ID
* @param request the updated modification data
* @return the updated modification response
*/
public ModificationResponse update(Long id, ModificationRequest request) {
Modification modification = modificationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
@@ -108,14 +145,22 @@ public class ModificationService {
return ModificationResponse.from(modificationRepository.save(modification));
}
@Transactional
/**
* Deletes a modification by its ID.
*
* @param id the modification ID to delete
*/
public void delete(Long id) {
Modification modification = modificationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
modificationRepository.delete(modification);
}
@Transactional
/**
* Deletes multiple modifications in a single batch operation.
*
* @param ids list of modification IDs to delete
*/
public void batchDelete(List<Long> ids) {
Set<Long> uniqueIds = new LinkedHashSet<>(ids);
List<Modification> modifications = modificationRepository.findAllById(uniqueIds);
@@ -6,11 +6,15 @@ import com.onixbyte.deltaforceguide.manager.UserCredentialManager;
import com.onixbyte.deltaforceguide.manager.UserManager;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* Service for user account management and profile operations.
*
* @author zihluwang
*/
@Service
public class UserService {
@@ -22,29 +26,53 @@ public class UserService {
this.userCredentialManager = userCredentialManager;
}
@Transactional(readOnly = true)
/**
* Retrieves all registered users.
*
* @return list of all users
*/
public List<User> findAll() {
return userManager.findAll();
}
@Transactional(readOnly = true)
/**
* Finds a user by their ID.
*
* @param id the user ID
* @return the user
*/
public User queryById(Long id) {
return userManager.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id));
}
@Transactional(readOnly = true)
/**
* Finds a user by their username.
*
* @param username the username to search for
* @return the user
*/
public User queryByUsername(String username) {
return userManager.findByUsername(username)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + username));
}
@Transactional
/**
* Creates a new user account.
*
* @param user the user entity to persist
* @return the saved user entity
*/
public User create(User user) {
return userManager.save(user);
}
@Transactional
/**
* Updates an existing user account.
*
* @param user the user entity with updated fields
* @return the saved user entity
*/
public User update(User user) {
if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId());
@@ -52,13 +80,24 @@ public class UserService {
return userManager.save(user);
}
@Transactional(readOnly = true)
/**
* Finds all credentials associated with a user.
*
* @param userId the user ID
* @return list of user credentials
*/
public List<UserCredential> findCredentials(Long userId) {
ensureUserExists(userId);
return userCredentialManager.findAllByUserId(userId);
}
@Transactional(readOnly = true)
/**
* Queries a specific credential for a user by provider.
*
* @param userId the user ID
* @param provider the authentication provider
* @return the matching credential
*/
public UserCredential queryCredential(Long userId, String provider) {
ensureUserExists(userId);
return userCredentialManager.findByUserIdAndProvider(userId, provider)
@@ -67,7 +106,14 @@ public class UserService {
"User credential not found: userId=" + userId + ", provider=" + provider));
}
@Transactional
/**
* Creates or updates a credential for a user and provider.
*
* @param userId the user ID
* @param provider the authentication provider
* @param credential the credential value
* @return the saved credential
*/
public UserCredential upsertCredential(Long userId, String provider, String credential) {
User user = ensureUserExists(userId);
UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider)
@@ -78,13 +124,22 @@ public class UserService {
return userCredentialManager.save(userCredential);
}
@Transactional
/**
* Deletes a specific credential for a user by provider.
*
* @param userId the user ID
* @param provider the authentication provider
*/
public void deleteCredential(Long userId, String provider) {
ensureUserExists(userId);
userCredentialManager.deleteByUserIdAndProvider(userId, provider);
}
@Transactional
/**
* Deletes a user and all associated credentials.
*
* @param id the user ID to delete
*/
public void deleteById(Long id) {
ensureUserExists(id);
userCredentialManager.deleteAllByUserId(id);
@@ -1,15 +1,14 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest;
import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.TuningRequest;
import com.onixbyte.deltaforceguide.domain.dto.*;
import com.onixbyte.deltaforceguide.manager.ModificationManager;
import com.onixbyte.deltaforceguide.manager.WebhookManager;
import org.slf4j.Logger;
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;
@@ -25,7 +24,7 @@ public class WebhookService {
private static final String TRIGGER_LABEL = "weapon-mod";
private static final Duration DEDUP_TTL = Duration.ofHours(12);
private static final Pattern YAML_FENCE =
Pattern.compile("```ya?ml\\s*\\n(.*?)```", Pattern.DOTALL);
Pattern.compile("```ya?ml\\s*\\R(.*?)```", Pattern.DOTALL);
private final ModificationManager modificationManager;
private final RedisTemplate<String, Object> redisTemplate;
@@ -74,8 +73,7 @@ public class WebhookService {
return;
}
@SuppressWarnings("unchecked")
var data = (Map<String, Object>) yaml.load(parsedYaml);
var data = yaml.<Map<String, Object>>load(parsedYaml);
if (data == null) {
log.warn("Empty YAML block in issue #{}", issue.number());
return;
@@ -112,6 +110,10 @@ public class WebhookService {
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"));
@@ -168,9 +170,9 @@ public class WebhookService {
}
private boolean isAllowedSender(
com.onixbyte.deltaforceguide.domain.dto.GitHubWebhookSender sender
GitHubWebhookSender sender
) {
var allowedUsers = webhookManager.github().allowedUsers();
var allowedUsers = webhookManager.allowedUsers();
if (allowedUsers == null || allowedUsers.isEmpty()) {
return true;
}
@@ -180,7 +182,7 @@ public class WebhookService {
return allowedUsers.contains(sender.login());
}
private boolean hasTriggerLabel(List<com.onixbyte.deltaforceguide.domain.dto.GitHubWebhookLabel> labels) {
private boolean hasTriggerLabel(List<GitHubWebhookLabel> labels) {
if (labels == null) {
return false;
}
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.shared;
/**
* Constants for HTTP cookie names used for authentication tokens.
*
* @author zihluwang
*/
public class CookieName {
public static final String ACCESS_TOKEN = "AccessToken";
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.shared;
/**
* Constants for supported authentication provider identifiers.
*
* @author zihluwang
*/
public class CredentialProvider {
public static final String LOCAL = "LOCAL";
@@ -13,6 +13,12 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* Shared Jackson {@link com.fasterxml.jackson.databind.Module} instances for custom date/time
* serialisation and deserialisation across the application.
*
* @author zihluwang
*/
public class JacksonModules {
public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule();
@@ -4,6 +4,12 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
/**
* Singleton serialiser for Redis cache operations using
* {@link GenericJackson2JsonRedisSerializer} with JavaTime module support.
*
* @author zihluwang
*/
public class JacksonRedisSerialiser {
public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer();
@@ -4,8 +4,19 @@ import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
/**
* Utility class for date and time operations using system-default time zone.
*
* @author zihluwang
*/
public class DateTimeUtil {
/**
* Convert a {@link LocalDateTime} to an {@link Instant} using the system-default time zone.
*
* @param ldt the local date-time to convert
* @return the corresponding instant
*/
public static Instant asInstant(LocalDateTime ldt) {
return ldt.atZone(ZoneId.systemDefault())
.toInstant();
+6 -6
View File
@@ -39,13 +39,13 @@ mybatis:
type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler
mapper-locations: classpath:/mapper/*.xml
app:
webhook:
github:
secret: ${GITHUB_WEBHOOK_SECRET:}
allowed-users: []
logging:
level:
org.hibernate:
orm.connections.pooling: off
app:
common:
version: ${appVersion}
channel: ${channel}
vendor: ${vendor}