chore: sync main branch with develop (monorepo rename)

This commit is contained in:
siujamo committed 2026-08-28 09:52:50 +08:00
1 parent 8776f45684
commit 9e86447c45
182 files changed
+13333 -1308

No files matched your search

@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Entry point for the Ahsarah Guide Server application.
*
* @author zihluwang
*/
@EnableAsync
@SpringBootApplication
public class AhsarahGuideApplication {
public static void main(String[] args) {
SpringApplication.run(AhsarahGuideApplication.class, args);
}
}
@@ -0,0 +1,109 @@
package com.onixbyte.ahsarahguide.client;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@Component
public class EmailClient {
private static final Logger log = LoggerFactory.getLogger(EmailClient.class);
private final JavaMailSender mailSender;
private final TemplateEngine emailTemplateEngine;
@Autowired
public EmailClient(
JavaMailSender mailSender,
TemplateEngine emailTemplateEngine
) {
this.mailSender = mailSender;
this.emailTemplateEngine = emailTemplateEngine;
}
private String fromAddress;
@Autowired
public void setFromAddress(@Value("${spring.mail.username}") String fromAddress) {
this.fromAddress = fromAddress;
}
/**
* Send a template-based HTML email asynchronously.
*
* @param toRecipient recipient email address
* @param subject subject line
* @param templateName template name (without .html extension, under mail-templates/)
* @param variables context variables required by the template
*/
@Async
public void sendTemplateMail(String toRecipient, String subject, String templateName, Map<String, Object> variables) {
try {
// Render HTML content using the dedicated email template engine
var context = new Context();
if (variables != null && !variables.isEmpty()) {
context.setVariables(variables);
}
var htmlBody = emailTemplateEngine.process(templateName, context);
log.debug("htmlBody={}", htmlBody);
// Build the MIME message
var message = mailSender.createMimeMessage();
var helper = new MimeMessageHelper(
message,
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
StandardCharsets.UTF_8.name()
);
helper.setFrom(new InternetAddress(fromAddress, "阿萨拉向导", StandardCharsets.UTF_8.name()));
helper.setTo(toRecipient);
helper.setSubject(subject);
helper.setText(htmlBody, true);
// Dispatch the message
mailSender.send(message);
log.info("Successfully dispatched email to: [{}] with subject: [{}]", toRecipient, subject);
} catch (MessagingException | UnsupportedEncodingException ex) {
log.error("Failed to construct or send email to: [{}], subject: [{}]", toRecipient, subject, ex);
// Handle or rethrow custom business exception if required
} catch (Exception ex) {
log.error("Unexpected error occurred while processing email for: [{}]", toRecipient, ex);
}
}
/**
* Convenience method specifically for sending registration/login verification codes.
*
* @param toRecipient target recipient email
* @param username display username
* @param code verification code string
* @param expireMinutes expiration time in minutes
*/
@Async
public void sendVerificationCode(String toRecipient, String username, String code, int expireMinutes) {
var subject = "【阿萨拉向导】账号注册验证码";
var variables = Map.<String, Object>of(
"username", username,
"code", code,
"expireMinutes", expireMinutes
);
sendTemplateMail(toRecipient, subject, "verification-code", variables);
}
}
@@ -0,0 +1,75 @@
package com.onixbyte.ahsarahguide.client;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.properties.TokenProperties;
import com.onixbyte.ahsarahguide.utils.DateTimeUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* Client for generating and verifying JSON Web Tokens using the Auth0 java-jwt library.
*
* @author zihluwang
*/
@Component
public class TokenClient {
private final Algorithm algorithm;
private final TokenProperties tokenProperties;
private final JWTVerifier verifier;
/**
* Constructs a new TokenClient with the necessary algorithm and token properties.
*
* @param algorithm the signing algorithm used to secure the JWT
* @param tokenProperties the configuration properties for the token, such as issuer and
* validity period
*/
@Autowired
public TokenClient(
Algorithm algorithm,
TokenProperties tokenProperties,
JWTVerifier verifier
) {
this.algorithm = algorithm;
this.tokenProperties = tokenProperties;
this.verifier = verifier;
}
/**
* Generate a JSON Web Token to the current user.
*
* @param user the current user for whom the token is being generated
* @return a JWT string
*/
public String generateToken(User user) {
var issuedAt = LocalDateTime.now();
var expiresAt = issuedAt.plus(tokenProperties.validTime());
return JWT.create()
.withSubject(user.getUsername())
.withIssuer(tokenProperties.issuer())
.withIssuedAt(DateTimeUtil.asInstant(issuedAt))
.withExpiresAt(DateTimeUtil.asInstant(expiresAt))
.sign(algorithm);
}
/**
* Verify and decode token.
*
* @param token a JWT token
* @return information included in the given token
* @throws com.auth0.jwt.exceptions.JWTVerificationException if the token is invalid, such as
* expired, or not signed by
* specific server
*/
public DecodedJWT verifyToken(String token) {
return verifier.verify(token);
}
}
@@ -0,0 +1,48 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.interceptor.GitHubWebhookInterceptor;
import com.onixbyte.ahsarahguide.interceptor.TrafficInterceptor;
import com.onixbyte.ahsarahguide.properties.AppProperties;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUserArgumentResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
@EnableConfigurationProperties(AppProperties.class)
public class AppConfig implements WebMvcConfigurer {
private final TrafficInterceptor trafficInterceptor;
private final GitHubWebhookInterceptor gitHubWebhookInterceptor;
private final CurrentUserArgumentResolver currentUserArgumentResolver;
@Autowired
public AppConfig(
TrafficInterceptor trafficInterceptor,
GitHubWebhookInterceptor gitHubWebhookInterceptor,
CurrentUserArgumentResolver currentUserArgumentResolver
) {
this.trafficInterceptor = trafficInterceptor;
this.gitHubWebhookInterceptor = gitHubWebhookInterceptor;
this.currentUserArgumentResolver = currentUserArgumentResolver;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(trafficInterceptor);
registry.addInterceptor(gitHubWebhookInterceptor)
.addPathPatterns("/webhooks/github");
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
}
@@ -0,0 +1,102 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.shared.JacksonRedisSerialiser;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import java.time.Duration;
/**
* Configuration class for Redis-based caching components.
* <p>
* This configuration class provides beans for Redis cache management and template operations
* within the Helix application. It configures custom serialisation strategies using
* {@link GenericJackson2JsonRedisSerializer} for values and string serialisation for keys,
* ensuring optimal performance and compatibility with JSON-based data structures.
* <p>
* The configuration includes:
* <ul>
* <li>Custom {@link RedisCacheManager} with JSON serialisation support</li>
* <li>Configured {@link RedisTemplate} for direct Redis operations</li>
* </ul>
*
* @author zihluwang
* @see RedisCacheManager
* @see RedisTemplate
* @see GenericJackson2JsonRedisSerializer
* @since 1.0.0
*/
@Configuration
@EnableCaching
public class CacheConfig {
/**
* Creates a custom Redis cache manager with JSON serialisation support.
* <p>
* This method configures a {@link RedisCacheManager} that uses string serialisation for cache
* keys and {@link GenericJackson2JsonRedisSerializer} for cache values. This setup ensures that
* complex objects can be stored and retrieved from Redis cache whilst maintaining readability
* and compatibility with JSON-based systems.
*
* @param connectionFactory the Redis connection factory used to establish connections
* @return a configured {@link RedisCacheManager} with custom serialisation settings
* @see RedisCacheManager
* @see GenericJackson2JsonRedisSerializer
* @see RedisSerializationContext
*/
@Bean
public RedisCacheManager cacheManager(
RedisConnectionFactory connectionFactory
) {
var _keySerializer = RedisSerializer.string();
var cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(_keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(JacksonRedisSerialiser.INSTANCE))
.entryTtl(Duration.ofMinutes(90L));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
/**
* Creates a Redis template for direct Redis operations with custom serialisation.
* <p>
* This method configures a {@link RedisTemplate} that uses string serialisation for keys
* and {@link GenericJackson2JsonRedisSerializer} for values. This template provides low-level
* access to Redis operations whilst ensuring consistent serialisation strategies across
* the application.
* <p>
* The template is fully configured and ready for use after bean creation.
*
* @param connectionFactory the Redis connection factory used to establish connections
* @return a fully configured {@link RedisTemplate} for Redis operations
* @see RedisTemplate
* @see GenericJackson2JsonRedisSerializer
* @see RedisSerializer
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory
) {
var redisTemplate = new RedisTemplate<String, Object>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(JacksonRedisSerialiser.INSTANCE);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
@@ -0,0 +1,49 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.properties.CorsProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
import java.util.stream.Stream;
/**
* Configuration for CORS (Cross-Origin Resource Sharing) policies.
*
* @author zihluwang
*/
@Configuration
@EnableConfigurationProperties({CorsProperties.class})
public class CorsConfig {
/**
* Creates the CORS configuration source with properties from configuration.
*
* @param properties the CORS configuration properties
* @return the CORS configuration source
*/
@Bean
public CorsConfigurationSource corsConfigurationSource(
CorsProperties properties
) {
var corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(properties.allowCredentials());
corsConfiguration.setAllowedOrigins(List.of(properties.allowedOrigins()));
corsConfiguration.setAllowedHeaders(List.of(properties.allowedHeaders()));
corsConfiguration.setAllowedMethods(Stream.of(properties.allowedMethods())
.map(HttpMethod::name)
.toList());
corsConfiguration.setMaxAge(properties.maxAge());
corsConfiguration.setAllowPrivateNetwork(properties.allowPrivateNetwork());
corsConfiguration.setExposedHeaders(List.of(properties.exposedHeaders()));
var corsConfigurationSource = new UrlBasedCorsConfigurationSource();
corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
return corsConfigurationSource;
}
}
@@ -0,0 +1,37 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.captcha.text.TextProducer;
import com.onixbyte.captcha.text.impl.DefaultTextProducer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import java.nio.charset.StandardCharsets;
@Configuration
public class EmailTemplateConfig {
@Bean
public TemplateEngine emailTemplateEngine() {
var resolver = new ClassLoaderTemplateResolver();
// Template files are located under src/main/resources/mail-templates/
resolver.setPrefix("mail-templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
resolver.setCacheable(true); // Enable caching for the production environment
var templateEngine = new SpringTemplateEngine();
templateEngine.addTemplateResolver(resolver);
return templateEngine;
}
@Bean
public TextProducer textProducer() {
return DefaultTextProducer.builder().build();
}
}
@@ -0,0 +1,21 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.filter.WebhookFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<WebhookFilter> webhookFilterBean(WebhookFilter webhookFilter) {
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
registrationBean.setFilter(webhookFilter);
registrationBean.addUrlPatterns("/webhooks/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
@@ -0,0 +1,25 @@
package com.onixbyte.ahsarahguide.config;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.onixbyte.ahsarahguide.shared.JacksonModules;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for Jackson JSON serialisation and deserialisation settings.
*
* @author zihluwang
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomiser() {
return (builder) -> {
builder.modules(JacksonModules.DATE_TIME_MODULE);
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
};
}
}
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for MyBatis SQL mapping framework integration.
*
* @author zihluwang
*/
@Configuration
@MapperScan(basePackages = {"com.onixbyte.ahsarahguide.mapper"})
public class MyBatisConfig {
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
import org.springframework.context.annotation.Configuration;
@OpenAPIDefinition(
info = @Info(
title = "Ahsarah Guide Server",
description = "API for Ahsarah Guide",
version = "1.4.0",
contact = @Contact(
name = "Zihlu Wang",
email = "zihlu.wang@onixbyte.com"
),
license = @License(
name = "MIT",
url = "https://onixbyte.dev/onixbyte/ahsarah-guide/raw/branch/main/LICENCE"
)
)
)
@Configuration
public class OpenApiConfiguration {
}
@@ -0,0 +1,113 @@
package com.onixbyte.ahsarahguide.config;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.onixbyte.ahsarahguide.exeption.ForbiddenException;
import com.onixbyte.ahsarahguide.exeption.UnauthorisedException;
import com.onixbyte.ahsarahguide.filter.TokenAuthenticationFilter;
import com.onixbyte.ahsarahguide.properties.CookieProperties;
import com.onixbyte.ahsarahguide.properties.TokenProperties;
import com.onixbyte.ahsarahguide.security.provider.UsernamePasswordAuthenticationProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
/**
* Spring Security configuration defining authentication, authorisation, and filter chains.
*
* @author zihluwang
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
/**
* Configures the HTTP security filter chain including endpoint authorisation and JWT filter.
*
* @param httpSecurity the HTTP security builder
* @return the configured security filter chain
*/
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity,
CorsConfigurationSource corsConfigurationSource,
TokenAuthenticationFilter tokenAuthenticationFilter
) throws Exception {
return httpSecurity
.cors((cors) -> cors
.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((customiser) -> customiser
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((customiser) -> customiser
.anyRequest().permitAll()
)
.exceptionHandling(customiser -> customiser
.authenticationEntryPoint((request, response, authException) -> {
log.error("Unauthenticated request: {}", request, authException);
throw new UnauthorisedException("请先登录");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
log.error("Denied request: {}", request, accessDeniedException);
throw new ForbiddenException("权限不足");
})
)
.addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class)
.build();
}
/**
* Provides the BCrypt password encoder for credential hashing.
*
* @return the BCrypt password encoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Provides the authentication manager for the security configuration.
*
* @return the authentication manager
*/
@Bean
public AuthenticationManager authenticationManager(
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
) {
return new ProviderManager(
usernamePasswordAuthenticationProvider
);
}
@Bean
public Algorithm algorithm(TokenProperties properties) {
return Algorithm.HMAC256(properties.secret());
}
@Bean
public JWTVerifier verifier(Algorithm algorithm, TokenProperties tokenProperties) {
return JWT.require(algorithm)
.withIssuer(tokenProperties.issuer())
.build();
}
}
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Configuration for Spring Data JPA auditing and repository settings.
*
* @author zihluwang
*/
@Configuration
@EnableJpaRepositories(basePackages = {"com.onixbyte.ahsarahguide.repository"})
public class SpringDataConfig {
}
@@ -0,0 +1,10 @@
package com.onixbyte.ahsarahguide.config;
import com.onixbyte.ahsarahguide.properties.GitHubWebhookProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({GitHubWebhookProperties.class})
public class WebhookConfig {
}
@@ -0,0 +1,59 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ManageRoleRequest;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for administrative user and role management.
*
* @author zihluwang
*/
@Tag(name = "管理员", description = "管理员用户与角色管理")
@RestController
@RequestMapping("/admin")
@Validated
@RequiresAuth
public class AdminController {
private final UserService userService;
public AdminController(UserService userService) {
this.userService = userService;
}
@Operation(description = "为用户分配角色(仅超级管理员可操作)")
@PreAuthorize("hasRole('SUPER_USER')")
@PutMapping("/users/{id}/roles")
public ResponseEntity<Void> assignRole(
@PathVariable @Positive(message = "用户ID必须为正数") Long id,
@Valid @RequestBody ManageRoleRequest request
) {
userService.assignRole(id, request.role());
return ResponseEntity.noContent().build();
}
@Operation(description = "移除用户角色(仅超级管理员可操作)")
@PreAuthorize("hasRole('SUPER_USER')")
@DeleteMapping("/users/{id}/roles/{role}")
public ResponseEntity<Void> removeRole(
@PathVariable @Positive(message = "用户ID必须为正数") Long id,
@PathVariable String role
) {
userService.removeRole(id, role);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,62 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.*;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for user authentication endpoints (login, logout).
*
* @author zihluwang
*/
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@Operation(description = "用户登录")
@PostMapping("/login")
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
var user = authService.login(request);
return authService.getUserResponseResponseEntity(user);
}
@RequiresAuth
@Operation(description = "退出登录")
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
var expiredCookie = authService.logout();
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
.build();
}
@Operation(description = "发送邮件验证码")
@PostMapping("/verification-code")
public SendVerificationCodeResponse sendVerificationCode(@Validated @RequestBody SendVerificationCodeRequest request) {
var verificationCodeId = authService.sendVerificationCode(request);
return new SendVerificationCodeResponse(verificationCodeId);
}
@Operation(description = "用户注册")
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@Validated @RequestBody RegisterRequest request) {
var user = authService.register(request);
return authService.getUserResponseResponseEntity(user);
}
}
@@ -0,0 +1,32 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.ahsarahguide.service.DailyPasswordService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for retrieving daily-generated passwords.
*
* @author zihluwang
*/
@Tag(name = "每日密码", description = "获取每日密码信息")
@RestController
@RequestMapping("/daily-passwords")
public class DailyPasswordController {
private final DailyPasswordService dailyPasswordService;
public DailyPasswordController(DailyPasswordService dailyPasswordService) {
this.dailyPasswordService = dailyPasswordService;
}
@Operation(description = "获取当日的每日密码数据,该数据将被缓存一天")
@GetMapping
public DailyPasswordResponse getDailyPassword() {
return dailyPasswordService.getDailyPassword();
}
}
@@ -0,0 +1,72 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.FirearmRequest;
import com.onixbyte.ahsarahguide.domain.dto.FirearmResponse;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.service.FirearmService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* REST controller for firearm CRUD operations.
*
* @author zihluwang
*/
@Tag(name = "武器管理", description = "与武器有关的操作")
@RestController
@RequestMapping("/firearms")
public class FirearmController {
private final FirearmService firearmService;
public FirearmController(FirearmService firearmService) {
this.firearmService = firearmService;
}
@Operation(description = "获取分页武器数据")
@Validated
@GetMapping
public PageResponse<FirearmResponse> pageQuery(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(defaultValue = "id") String sortBy,
@RequestParam(defaultValue = "DESC") Sort.Direction direction,
@RequestParam(required = false) FirearmType type
) {
return firearmService.pageQuery(type, PageRequest.of(page, size, Sort.by(direction, sortBy)));
}
@Operation(description = "获取指定武器的数据")
@GetMapping("/{id}")
public FirearmResponse queryById(@PathVariable Long id) {
return firearmService.queryById(id);
}
@RequiresAuth
@PostMapping
public FirearmResponse addFirearm(@Validated @RequestBody FirearmRequest request) {
return firearmService.addFirearm(request);
}
@RequiresAuth
@Operation(description = "更新指定武器的数据")
@PutMapping("/{id}")
public FirearmResponse updateFirearm(@PathVariable Long id, @Validated @RequestBody FirearmRequest request) {
return firearmService.updateFirearm(id, request);
}
@RequiresAuth
@Operation(description = "删除指定武器的数据")
@DeleteMapping("/{id}")
public void deleteFirearm(@PathVariable Long id) {
firearmService.deleteFirearm(id);
}
}
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
/**
* Global exception handler that translates exceptions into standard error responses.
*
* @author zihluwang
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(ResponseStatusException exception) {
var status = exception.getStatusCode();
return ResponseEntity.status(status)
.body(new ErrorResponse(exception.getReason()));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("请先登录"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException exception) {
var authentication = SecurityContextHolder.getContext().getAuthentication();
var httpStatus = authentication == null || !authentication.isAuthenticated()
? HttpStatus.UNAUTHORIZED
: HttpStatus.FORBIDDEN;
var message = httpStatus == HttpStatus.UNAUTHORIZED ? "请先登录" : "权限不足";
return ResponseEntity.status(httpStatus)
.body(new ErrorResponse(message));
}
}
@@ -0,0 +1,109 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.ModificationBatchCreateRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationResponse;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUser;
import com.onixbyte.ahsarahguide.service.ModificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* REST controller for modification CRUD operations, including batch creation and deletion.
*
* @author zihluwang
*/
@Tag(name = "改装管理", description = "对枪械改装的管理")
@RestController
@RequestMapping("/modifications")
public class ModificationController {
private final ModificationService modificationService;
public ModificationController(ModificationService modificationService) {
this.modificationService = modificationService;
}
@Operation(description = "分页查询改装信息")
@Validated
@GetMapping
public PageResponse<ModificationResponse> pageQuery(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(required = false) @Positive Long firearmId,
@RequestParam(defaultValue = "id") String sortBy,
@RequestParam(defaultValue = "DESC") Sort.Direction direction,
@RequestParam(required = false) List<String> tags
) {
return modificationService.pageQuery(firearmId, tags, PageRequest.of(page, size, Sort.by(direction, sortBy)));
}
@Operation(description = "查询指定改装的信息")
@GetMapping("/{id}")
public ModificationResponse queryById(@PathVariable Long id) {
return modificationService.queryById(id);
}
@RequiresAuth
@Operation(description = "创建改装")
@PostMapping
public ModificationResponse create(
@CurrentUser User user,
@Valid @RequestBody ModificationRequest request
) {
return modificationService.create(request, user);
}
@RequiresAuth
@Operation(description = "批量创建改装")
@PostMapping("/batch")
public List<ModificationResponse> batchCreate(
@CurrentUser User user,
@Valid @RequestBody ModificationBatchCreateRequest request
) {
return modificationService.batchCreate(request.modifications(), user);
}
@RequiresAuth
@Operation(description = "修改指定改装")
@PutMapping("/{id}")
public ModificationResponse update(@PathVariable Long id, @Valid @RequestBody ModificationRequest request) {
return modificationService.update(id, request);
}
@RequiresAuth
@Operation(description = "删除指定改装")
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
modificationService.delete(id);
}
@RequiresAuth
@Operation(description = "批量删除改装")
@DeleteMapping("/batch-delete")
@Validated
public void batchDelete(@RequestParam List<@Positive Long> ids) {
modificationService.batchDelete(ids);
}
}
@@ -0,0 +1,34 @@
package com.onixbyte.ahsarahguide.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.onixbyte.ahsarahguide.service.ModificationService;
import java.util.List;
/**
* REST controller for retrieving available modification tags.
*
* @author zihluwang
*/
@Tag(name = "标签管理", description = "管理标签信息")
@RestController
@RequestMapping("/tags")
public class TagController {
private final ModificationService modificationService;
public TagController(ModificationService modificationService) {
this.modificationService = modificationService;
}
@Operation(description = "查询指定武器或所有武器的标签")
@GetMapping
public List<String> getTags(@RequestParam(required = false) Long firearmId) {
return modificationService.findAllTags(firearmId);
}
}
@@ -0,0 +1,79 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.BuildSummaryResponse;
import com.onixbyte.ahsarahguide.domain.dto.ChangePasswordRequest;
import com.onixbyte.ahsarahguide.domain.dto.PageResponse;
import com.onixbyte.ahsarahguide.domain.dto.UpdateProfileRequest;
import com.onixbyte.ahsarahguide.domain.dto.UserProfileResponse;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.security.annotation.RequiresAuth;
import com.onixbyte.ahsarahguide.security.resolver.CurrentUser;
import com.onixbyte.ahsarahguide.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for the current user's profile management endpoints.
*
* @author zihluwang
*/
@Tag(name = "用户个人中心", description = "当前用户的资料管理与密码修改")
@RestController
@RequestMapping("/user")
@Validated
@RequiresAuth
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@Operation(description = "获取当前登录用户的个人资料")
@GetMapping("/me")
public UserProfileResponse profile(@CurrentUser User user) {
return userService.getProfile(user);
}
@Operation(description = "修改当前用户的个人资料(昵称与邮箱)")
@PutMapping("/me")
public UserProfileResponse updateProfile(
@CurrentUser User user,
@Valid @RequestBody UpdateProfileRequest request
) {
return userService.updateProfile(user, request);
}
@Operation(description = "修改当前用户的登录密码")
@PutMapping("/me/password")
public ResponseEntity<Void> changePassword(
@CurrentUser User user,
@Valid @RequestBody ChangePasswordRequest request
) {
userService.changePassword(user, request);
return ResponseEntity.noContent().build();
}
@Operation(description = "分页查询当前用户的改枪码列表")
@GetMapping("/me/builds")
public PageResponse<BuildSummaryResponse> builds(
@CurrentUser User user,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size
) {
return userService.getBuilds(user, PageRequest.of(page, size));
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.service.AppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "版本信息")
@RestController
@RequestMapping("/versions")
public class VersionController {
private final AppService appService;
public VersionController(AppService appService) {
this.appService = appService;
}
@Operation(description = "获取当前应用版本号")
@GetMapping
public String getVersion() {
return appService.getVersion();
}
}
@@ -0,0 +1,47 @@
package com.onixbyte.ahsarahguide.controller;
import com.onixbyte.ahsarahguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.ahsarahguide.service.WebhookService;
import com.onixbyte.ahsarahguide.shared.GitHubWebhookHeader;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "WebHook")
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private static final Logger log = LoggerFactory.getLogger(WebhookController.class);
private final WebhookService webhookService;
public WebhookController(WebhookService webhookService) {
this.webhookService = webhookService;
}
@Operation(description = "GitHub WebHook 接口")
@PostMapping("/github")
public ResponseEntity<Void> handleWebhook(
@RequestHeader(GitHubWebhookHeader.EVENT) String event,
@RequestBody GitHubIssueRequest request
) {
if (!"issues".equals(event)) {
log.debug("Ignoring non-issue event: {}", event);
return ResponseEntity.ok().build();
}
if (!"opened".equals(request.action())) {
log.debug("Ignoring issue action: {}", request.action());
return ResponseEntity.ok().build();
}
webhookService.processIssueEvent(request);
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.domain.converter;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* JPA attribute converter that maps {@link FirearmType} enum to/from its integer database representation.
*
* @author zihluwang
*/
@Converter
public class FirearmTypeConverter implements AttributeConverter<FirearmType, Integer> {
@Override
public Integer convertToDatabaseColumn(FirearmType attribute) {
return attribute == null ? null : attribute.getCode();
}
@Override
public FirearmType convertToEntityAttribute(Integer dbData) {
return FirearmType.fromCode(dbData);
}
}
@@ -0,0 +1,28 @@
package com.onixbyte.ahsarahguide.domain.converter;
import com.onixbyte.ahsarahguide.enumeration.ModificationStatus;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Optional;
/**
* JPA attribute converter that maps {@link ModificationStatus} enum to/from its integer database representation.
*
* @author zihluwang
*/
@Converter
public class ModificationStatusConverter implements AttributeConverter<ModificationStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(ModificationStatus attribute) {
return Optional.ofNullable(attribute)
.map(ModificationStatus::getCode)
.orElse(null);
}
@Override
public ModificationStatus convertToEntityAttribute(Integer dbData) {
return ModificationStatus.fromCode(dbData);
}
}
@@ -0,0 +1,25 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;
/**
* Request DTO for creating or updating an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryRequest(
@NotBlank(message = "插槽名称不能为空")
String slotName,
@NotBlank(message = "配件名称不能为空")
String accessoryName,
List<@Valid TuningRequest> tunings
) {
public List<TuningRequest> tunings() {
return tunings == null ? new ArrayList<>() : tunings;
}
}
@@ -0,0 +1,27 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.Accessory;
import java.util.List;
/**
* Response DTO for an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryResponse(
String slotName,
String accessoryName,
List<TuningResponse> tunings
) {
public static AccessoryResponse from(Accessory accessory) {
return new AccessoryResponse(
accessory.getSlotName(),
accessory.getAccessoryName(),
accessory.getTunings() == null
? List.of()
: accessory.getTunings().stream().map(TuningResponse::from).toList()
);
}
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.domain.dto;
import java.util.List;
/**
* Response record summarising a user's custom modification build.
* This is a skeleton record whose {@code from()} factory will be fully implemented
* when the CustomModification entity is introduced in a future issue.
*
* @author zihluwang
*/
public record BuildSummaryResponse(
Long id,
String name,
String code,
String firearmName,
List<String> tags
) {
// from() factory to be implemented when CustomModification entity lands
}
@@ -0,0 +1,23 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* Request record for changing the current user's password.
*
* @author zihluwang
*/
public record ChangePasswordRequest(
@NotBlank(message = "旧密码不能为空")
String oldPassword,
@NotBlank(message = "新密码不能为空")
@Size(min = 8, max = 128, message = "密码长度必须在8-128个字符之间")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
message = "密码必须包含至少一个大写字母、一个小写字母和一个数字"
)
String newPassword
) {}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* DTO representing a single daily-generated password for a map.
*
* @author zihluwang
*/
public record DailyPassword(
String mapName,
String password
) {
}
@@ -0,0 +1,19 @@
package com.onixbyte.ahsarahguide.domain.dto;
import java.time.LocalDateTime;
import java.util.List;
/**
* DTO containing daily password data including update information and password list.
*
* @author zihluwang
*/
public record DailyPasswordData(
String updateDate,
Integer totalCount,
List<DailyPassword> passwords,
String source,
LocalDateTime lastUpdated,
Long timestamp
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* DTO holding metadata about the daily password source and update tracking.
*
* @author zihluwang
*/
public record DailyPasswordMetadata(
String version,
String author
) {
}
@@ -0,0 +1,14 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* Response DTO wrapping daily password data with metadata.
*
* @author zihluwang
*/
public record DailyPasswordResponse(
String status,
String message,
DailyPasswordData data,
DailyPasswordMetadata metadata
) {
}
@@ -0,0 +1,8 @@
package com.onixbyte.ahsarahguide.domain.dto;
public record EmailVerificationCode(
String username,
String email,
String code
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
/**
* Standard error response body returned on API failures.
*
* @author zihluwang
*/
public record ErrorResponse(
String message
) {
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
/**
* Request DTO for creating or updating a firearm.
*
* @author zihluwang
*/
public record FirearmRequest(
String name,
FirearmType type,
String level,
String calibre,
Integer fireRate,
Integer armourDamage,
Integer bodyDamage,
String review
) {
}
@@ -0,0 +1,36 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.Firearm;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
/**
* Response DTO for a firearm record, including associated modifications.
*
* @author zihluwang
*/
public record FirearmResponse(
Long id,
String name,
FirearmType type,
String level,
String calibre,
Integer fireRate,
Integer armourDamage,
Integer bodyDamage,
String review
) {
public static FirearmResponse from(Firearm firearm) {
return new FirearmResponse(
firearm.getId(),
firearm.getName(),
firearm.getType(),
firearm.getLevel(),
firearm.getCalibre(),
firearm.getFireRate(),
firearm.getArmourDamage(),
firearm.getBodyDamage(),
firearm.getReview()
);
}
}
@@ -0,0 +1,15 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubIssueRequest(
String action,
GitHubWebhookIssue issue,
GitHubWebhookRepository repository,
GitHubWebhookSender sender
) {
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import java.util.List;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookIssue(
String url,
Long id,
Long number,
String title,
String body,
List<GitHubWebhookLabel> labels
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookLabel(
String name
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookRepository(
String fullName
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubWebhookSender(
String login
) {
}
@@ -0,0 +1,20 @@
package com.onixbyte.ahsarahguide.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
/**
* Login request containing principle (username/email) and credential (password).
*
* @author zihluwang
*/
@Schema(description = "登录请求")
public record LoginRequest(
@NotBlank(message = "登录名称不能为空")
@Schema(description = "用户名或电子邮箱", requiredMode = Schema.RequiredMode.REQUIRED)
String principle,
@NotBlank(message = "登录口令不能为空")
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
String credential
) {
}
@@ -0,0 +1,13 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
/**
* Request record for managing a user's role assignment.
*
* @author zihluwang
*/
public record ManageRoleRequest(
@NotBlank(message = "角色不能为空")
String role
) {}
@@ -0,0 +1,18 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* Request DTO for batch creation of modifications.
*
* @author zihluwang
*/
public record ModificationBatchCreateRequest(
@NotEmpty(message = "批量创建列表不能为空")
List<@Valid ModificationRequest> modifications
) {
}
@@ -0,0 +1,18 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* Request DTO for batch deletion of modifications by ID.
*
* @author zihluwang
*/
public record ModificationBatchDeleteRequest(
@NotEmpty(message = "批量删除ID列表不能为空")
List<@Positive(message = "ID必须为正数") Long> ids
) {
}
@@ -0,0 +1,38 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.util.ArrayList;
import java.util.List;
/**
* Request DTO for creating or updating a modification.
*
* @author zihluwang
*/
public record ModificationRequest(
@NotNull(message = "武器ID不能为空")
@Positive(message = "武器ID必须为正数")
Long firearmId,
@NotBlank(message = "改装名称不能为空")
String name,
@NotBlank(message = "改装代码不能为空")
String code,
List<@NotBlank(message = "标签不能为空") String> tags,
List<@Valid AccessoryRequest> accessories,
String note,
String author,
String videoUrl
) {
public List<String> tags() {
return tags == null ? new ArrayList<>() : tags;
}
public List<AccessoryRequest> accessories() {
return accessories == null ? new ArrayList<>() : accessories;
}
}
@@ -0,0 +1,39 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.Modification;
import java.util.List;
/**
* Response DTO for a modification record including accessories and tags.
*
* @author zihluwang
*/
public record ModificationResponse(
Long id,
Long firearmId,
String name,
String code,
List<String> tags,
List<AccessoryResponse> accessories,
String note,
String author,
String videoUrl
) {
public static ModificationResponse from(Modification modification) {
return new ModificationResponse(
modification.getId(),
modification.getFirearm().getId(),
modification.getName(),
modification.getCode(),
modification.getTags(),
modification.getAccessories() == null
? List.of()
: modification.getAccessories().stream().map(AccessoryResponse::from).toList(),
modification.getNote(),
modification.getAuthor(),
modification.getVideoUrl()
);
}
}
@@ -0,0 +1,29 @@
package com.onixbyte.ahsarahguide.domain.dto;
import org.springframework.data.domain.Page;
import java.util.List;
/**
* Generic paginated response wrapper for list endpoints.
*
* @author zihluwang
*/
public record PageResponse<T>(
List<T> items,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageResponse<T> from(Page<T> source) {
return new PageResponse<>(
source.getContent(),
source.getNumber(),
source.getSize(),
source.getTotalElements(),
source.getTotalPages()
);
}
}
@@ -0,0 +1,31 @@
package com.onixbyte.ahsarahguide.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
/**
* Registration request containing the new user account information.
*
* @author zihluwang
*/
@Schema(description = "注册请求")
public record RegisterRequest(
@NotBlank(message = "用户名不能为空")
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
String username,
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱", requiredMode = Schema.RequiredMode.REQUIRED)
String email,
@Schema(description = "昵称")
String nickname,
@Schema(description = "头像链接")
String avatarUrl,
@NotBlank(message = "密码不能为空")
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
String password,
@NotBlank(message = "验证码 ID 不能为空") String verificationCodeId,
@NotBlank(message = "验证码不能为空") String verificationCode
) {
}
@@ -0,0 +1,9 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
public record SendVerificationCodeRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "电子邮箱地址不能为空") String email
) {
}
@@ -0,0 +1,6 @@
package com.onixbyte.ahsarahguide.domain.dto;
public record SendVerificationCodeResponse(
String verificationCodeId
) {
}
@@ -0,0 +1,18 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningRequest(
@NotBlank(message = "调校项名称不能为空")
String tuningName,
@NotNull(message = "调校值不能为空")
Double tuningValue
) {
}
@@ -0,0 +1,21 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.Tuning;
/**
* Response DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningResponse(
String tuningName,
Double tuningValue
) {
public static TuningResponse from(Tuning tuning) {
return new TuningResponse(
tuning.getTuningName(),
tuning.getTuningValue()
);
}
}
@@ -0,0 +1,14 @@
package com.onixbyte.ahsarahguide.domain.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
/**
* Request record for updating the current user's profile information.
*
* @author zihluwang
*/
public record UpdateProfileRequest(
@Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间")
String nickname
) {}
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.User;
import java.time.LocalDateTime;
import java.util.List;
/**
* Response record for the current user's full profile information.
*
* @author zihluwang
*/
public record UserProfileResponse(
Long id,
String username,
String nickname,
String email,
String avatarUrl,
Boolean enabled,
LocalDateTime createdAt,
List<String> roles
) {
/**
* Creates a {@code UserProfileResponse} from a {@link User} entity and its assigned roles.
*
* @param user the user entity
* @param roles the list of role names assigned to the user
* @return the constructed response
*/
public static UserProfileResponse from(User user, List<String> roles) {
return new UserProfileResponse(
user.getId(),
user.getUsername(),
user.getNickname() == null || user.getNickname().isBlank()
? user.getUsername() : user.getNickname(),
user.getEmail(),
user.getAvatarUrl(),
user.getEnabled(),
user.getCreatedAt(),
roles
);
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.ahsarahguide.domain.dto;
import com.onixbyte.ahsarahguide.domain.entity.User;
import java.time.LocalDateTime;
/**
* Response DTO for a user account, including associated credentials.
*
* @author zihluwang
*/
public record UserResponse(
Long id,
String username,
String email,
LocalDateTime expiration
) {
public static UserResponse from(User user, LocalDateTime expiration) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
expiration
);
}
}
@@ -0,0 +1,72 @@
package com.onixbyte.ahsarahguide.domain.entity;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Entity representing an accessory attached to a modification, stored as JSONB.
*
* @author zihluwang
*/
public class Accessory {
private String slotName;
private String accessoryName;
private List<Tuning> tunings = new ArrayList<>();
public Accessory() {
}
public String getSlotName() {
return slotName;
}
public void setSlotName(String slotName) {
this.slotName = slotName;
}
public String getAccessoryName() {
return accessoryName;
}
public void setAccessoryName(String accessoryName) {
this.accessoryName = accessoryName;
}
public List<Tuning> getTunings() {
return tunings;
}
public void setTunings(List<Tuning> tunings) {
this.tunings = tunings;
}
public void addTuning(Tuning tuning) {
this.tunings.add(tuning);
}
public void removeTuning(Tuning tuning) {
this.tunings.remove(tuning);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Accessory accessory)) {
return false;
}
return Objects.equals(slotName, accessory.slotName)
&& Objects.equals(accessoryName, accessory.accessoryName)
&& Objects.equals(tunings, accessory.tunings);
}
@Override
public int hashCode() {
return Objects.hash(slotName, accessoryName, tunings);
}
}
@@ -0,0 +1,232 @@
package com.onixbyte.ahsarahguide.domain.entity;
import com.onixbyte.ahsarahguide.domain.converter.FirearmTypeConverter;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
/**
* Entity representing a firearm in the Delta Force game.
*
* @author zihluwang
*/
@Entity
@Table(name = "firearm")
public class Firearm {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "type", nullable = false)
@Convert(converter = FirearmTypeConverter.class)
private FirearmType type;
@Column(name = "level", nullable = false)
private String level;
@Column(name = "review", columnDefinition = "TEXT")
private String review;
@Column(name = "calibre")
private String calibre;
@Column(name = "fire_rate")
private Integer fireRate;
@Column(name = "armour_damage")
private Integer armourDamage;
@Column(name = "body_damage")
private Integer bodyDamage;
@OneToMany(mappedBy = "firearm", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Modification> modifications = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public FirearmType getType() {
return type;
}
public void setType(FirearmType type) {
this.type = type;
}
public String getLevel() {
return level;
}
public void setLevel(String level) {
this.level = level;
}
public String getReview() {
return review;
}
public void setReview(String review) {
this.review = review;
}
public String getCalibre() {
return calibre;
}
public void setCalibre(String calibre) {
this.calibre = calibre;
}
public Integer getFireRate() {
return fireRate;
}
public void setFireRate(Integer fireRate) {
this.fireRate = fireRate;
}
public Integer getArmourDamage() {
return armourDamage;
}
public void setArmourDamage(Integer armourDamage) {
this.armourDamage = armourDamage;
}
public Integer getBodyDamage() {
return bodyDamage;
}
public void setBodyDamage(Integer bodyDamage) {
this.bodyDamage = bodyDamage;
}
public List<Modification> getModifications() {
return modifications;
}
public void setModifications(List<Modification> modifications) {
this.modifications = modifications;
}
public void addModification(Modification modification) {
this.modifications.add(modification);
modification.setFirearm(this);
}
public void removeModification(Modification modification) {
this.modifications.remove(modification);
modification.setFirearm(null);
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long id;
private String name;
private FirearmType type;
private String level;
private String review;
private String calibre;
private Integer fireRate;
private Integer armourDamage;
private Integer bodyDamage;
private List<Modification> modifications;
public Builder id(Long id) {
this.id = id;
return this;
}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder type(FirearmType type) {
this.type = type;
return this;
}
public Builder level(String level) {
this.level = level;
return this;
}
public Builder review(String review) {
this.review = review;
return this;
}
public Builder calibre(String calibre) {
this.calibre = calibre;
return this;
}
public Builder fireRate(Integer fireRate) {
this.fireRate = fireRate;
return this;
}
public Builder armourDamage(Integer armourDamage) {
this.armourDamage = armourDamage;
return this;
}
public Builder bodyDamage(Integer bodyDamage) {
this.bodyDamage = bodyDamage;
return this;
}
public Builder modifications(List<Modification> modifications) {
this.modifications = modifications;
return this;
}
public Firearm build() {
Firearm firearm = new Firearm();
firearm.id = this.id;
firearm.name = this.name;
firearm.type = this.type;
firearm.level = this.level;
firearm.review = this.review;
firearm.calibre = this.calibre;
firearm.fireRate = this.fireRate;
firearm.armourDamage = this.armourDamage;
firearm.bodyDamage = this.bodyDamage;
firearm.modifications = this.modifications == null ? new ArrayList<>() : this.modifications;
return firearm;
}
}
}
@@ -0,0 +1,289 @@
package com.onixbyte.ahsarahguide.domain.entity;
import com.onixbyte.ahsarahguide.domain.converter.ModificationStatusConverter;
import com.onixbyte.ahsarahguide.enumeration.ModificationStatus;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Entity representing a firearm modification or build configuration.
*
* @author zihluwang
*/
@Entity
@Table(
name = "modification",
indexes = {
@Index(name = "idx_modification_firearm_id", columnList = "firearm_id")
}
)
public class Modification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "firearm_id", nullable = false, foreignKey = @ForeignKey(name = "fk_modification_firearm"))
private Firearm firearm;
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "code", nullable = false, length = 64)
private String code;
@Type(JsonType.class)
@Column(name = "tags", columnDefinition = "jsonb", nullable = false)
private List<String> tags = new ArrayList<>();
@Type(JsonType.class)
@Column(name = "accessories", columnDefinition = "jsonb", nullable = false)
private List<Accessory> accessories = new ArrayList<>();
@Column(name = "note", columnDefinition = "TEXT")
private String note;
@Column(name = "author", length = 64)
private String author;
@Column(name = "video_url", length = 512)
private String videoUrl;
@Column(name = "status", nullable = false)
@Convert(converter = ModificationStatusConverter.class)
private ModificationStatus status = ModificationStatus.DRAFT;
@Column(name = "create_by")
private Long createBy;
@Column(name = "create_time", nullable = false)
private OffsetDateTime createTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Firearm getFirearm() {
return firearm;
}
public void setFirearm(Firearm firearm) {
this.firearm = firearm;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public List<Accessory> getAccessories() {
return accessories;
}
public void setAccessories(List<Accessory> accessories) {
this.accessories = accessories;
}
public void addAccessory(Accessory modificationAccessory) {
this.accessories.add(modificationAccessory);
}
public void removeAccessory(Accessory modificationAccessory) {
this.accessories.remove(modificationAccessory);
}
public String getNote() {
return note;
}
public void setNote(String note) {
this.note = note;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getVideoUrl() {
return videoUrl;
}
public void setVideoUrl(String videoUrl) {
this.videoUrl = videoUrl;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Long getCreateBy() {
return createBy;
}
public void setCreateBy(Long createBy) {
this.createBy = createBy;
}
public OffsetDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(OffsetDateTime createTime) {
this.createTime = createTime;
}
public ModificationStatus getStatus() {
return status;
}
public void setStatus(ModificationStatus status) {
this.status = status;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long id;
private User user;
private Firearm firearm;
private String name;
private String code;
private List<String> tags;
private List<Accessory> accessories;
private String note;
private String author;
private String videoUrl;
private Long createBy;
private OffsetDateTime createTime;
private ModificationStatus status;
public Builder id(Long id) {
this.id = id;
return this;
}
public Builder user(User user) {
this.user = user;
return this;
}
public Builder firearm(Firearm firearm) {
this.firearm = firearm;
return this;
}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder code(String code) {
this.code = code;
return this;
}
public Builder tags(List<String> tags) {
this.tags = tags;
return this;
}
public Builder accessories(List<Accessory> accessories) {
this.accessories = accessories;
return this;
}
public Builder note(String note) {
this.note = note;
return this;
}
public Builder author(String author) {
this.author = author;
return this;
}
public Builder videoUrl(String videoUrl) {
this.videoUrl = videoUrl;
return this;
}
public Builder createBy(Long createBy) {
this.createBy = createBy;
return this;
}
public Builder createTime(OffsetDateTime createTime) {
this.createTime = createTime;
return this;
}
public Builder status(ModificationStatus status) {
this.status = status;
return this;
}
public Modification build() {
Modification modification = new Modification();
modification.id = this.id;
modification.user = this.user;
modification.firearm = this.firearm;
modification.name = this.name;
modification.code = this.code;
modification.tags = Optional.ofNullable(this.tags).orElseGet(ArrayList::new);
modification.accessories = Optional.ofNullable(this.accessories).orElseGet(ArrayList::new);
modification.note = this.note;
modification.author = this.author;
modification.videoUrl = this.videoUrl;
modification.createBy = this.createBy;
modification.createTime = Optional.ofNullable(this.createTime).orElseGet(OffsetDateTime::now);
modification.status = Optional.ofNullable(this.status).orElse(ModificationStatus.DRAFT);
return modification;
}
}
}
@@ -0,0 +1,50 @@
package com.onixbyte.ahsarahguide.domain.entity;
import java.util.Objects;
/**
* Entity representing a tuning adjustment for an accessory, stored as JSONB within Accessory.
*
* @author zihluwang
*/
public class Tuning {
private String tuningName;
private Double tuningValue;
public Tuning() {
}
public String getTuningName() {
return tuningName;
}
public void setTuningName(String tuningName) {
this.tuningName = tuningName;
}
public Double getTuningValue() {
return tuningValue;
}
public void setTuningValue(Double tuningValue) {
this.tuningValue = tuningValue;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Tuning tuning)) {
return false;
}
return Objects.equals(tuningName, tuning.tuningName)
&& Objects.equals(tuningValue, tuning.tuningValue);
}
@Override
public int hashCode() {
return Objects.hash(tuningName, tuningValue);
}
}
@@ -0,0 +1,217 @@
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entity representing an application user with authentication credentials.
*
* @author zihluwang
*/
@Entity
@Table(name = "app_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false)
private String username;
@Column(name = "email", nullable = false)
private String email;
@Column(name = "nickname")
private String nickname;
@Column(name = "avatar_url")
private String avatarUrl;
@Column(name = "enabled", nullable = false)
private Boolean enabled = true;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserCredential> credentials = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@PreUpdate
private void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
public List<UserCredential> getCredentials() {
return credentials;
}
public void setCredentials(List<UserCredential> credentials) {
this.credentials = credentials;
}
public void addCredential(UserCredential credential) {
this.credentials.add(credential);
credential.setUser(this);
}
public void removeCredential(UserCredential credential) {
this.credentials.remove(credential);
credential.setUser(null);
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long id;
private String username;
private String email;
private String nickname;
private String avatarUrl;
private Boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<UserCredential> credentials;
public Builder id(Long id) {
this.id = id;
return this;
}
public Builder username(String username) {
this.username = username;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder nickname(String nickname) {
this.nickname = nickname;
return this;
}
public Builder avatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
return this;
}
public Builder enabled(Boolean enabled) {
this.enabled = enabled;
return this;
}
public Builder createdAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
return this;
}
public Builder updatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
return this;
}
public Builder credentials(List<UserCredential> credentials) {
this.credentials = credentials;
return this;
}
public User build() {
User user = new User();
user.id = this.id;
user.username = this.username;
user.email = this.email;
user.nickname = this.nickname == null || this.nickname.isBlank()
? this.username : this.nickname;
user.avatarUrl = this.avatarUrl;
user.enabled = this.enabled == null || this.enabled;
user.createdAt = this.createdAt;
user.updatedAt = this.updatedAt;
user.credentials = this.credentials == null ? new ArrayList<>() : this.credentials;
return user;
}
}
}
@@ -0,0 +1,143 @@
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
/**
* Entity representing a user credential linked to an authentication provider.
*
* @author zihluwang
*/
@Entity
@Table(name = "app_user_credential")
public class UserCredential {
@EmbeddedId
@AttributeOverrides({
@AttributeOverride(name = "userId", column = @Column(name = "user_id")),
@AttributeOverride(name = "provider", column = @Column(name = "provider"))
})
private UserCredentialId id = new UserCredentialId();
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user"))
private User user;
@Column(name = "credential", nullable = false)
private String credential;
public UserCredentialId getId() {
return id;
}
public void setId(UserCredentialId id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
if (this.id == null) {
this.id = new UserCredentialId();
}
this.id.setUserId(user == null ? null : user.getId());
}
public Long getUserId() {
return id == null ? null : id.getUserId();
}
public void setUserId(Long userId) {
if (this.id == null) {
this.id = new UserCredentialId();
}
this.id.setUserId(userId);
}
public String getProvider() {
return id == null ? null : id.getProvider();
}
public void setProvider(String provider) {
if (this.id == null) {
this.id = new UserCredentialId();
}
this.id.setProvider(provider);
}
public String getCredential() {
return credential;
}
public void setCredential(String credential) {
this.credential = credential;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private UserCredentialId id;
private User user;
private Long userId;
private String provider;
private String credential;
public Builder id(UserCredentialId id) {
this.id = id;
return this;
}
public Builder user(User user) {
this.user = user;
return this;
}
public Builder userId(Long userId) {
this.userId = userId;
return this;
}
public Builder provider(String provider) {
this.provider = provider;
return this;
}
public Builder credential(String credential) {
this.credential = credential;
return this;
}
public UserCredential build() {
UserCredential userCredential = new UserCredential();
userCredential.id = this.id == null ? new UserCredentialId() : this.id;
userCredential.user = this.user;
if (this.user != null) {
userCredential.id.setUserId(this.user.getId());
}
if (this.userId != null) {
userCredential.id.setUserId(this.userId);
}
if (this.provider != null) {
userCredential.id.setProvider(this.provider);
}
userCredential.credential = this.credential;
return userCredential;
}
}
}
@@ -0,0 +1,81 @@
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
/**
* Composite key for the UserCredential entity, combining user ID and provider.
*
* @author zihluwang
*/
@Embeddable
public class UserCredentialId implements Serializable {
private Long userId;
private String provider;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UserCredentialId that = (UserCredentialId) o;
return Objects.equals(userId, that.userId) && Objects.equals(provider, that.provider);
}
@Override
public int hashCode() {
return Objects.hash(userId, provider);
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long userId;
private String provider;
public Builder userId(Long userId) {
this.userId = userId;
return this;
}
public Builder provider(String provider) {
this.provider = provider;
return this;
}
public UserCredentialId build() {
UserCredentialId id = new UserCredentialId();
id.userId = this.userId;
id.provider = this.provider;
return id;
}
}
}
@@ -0,0 +1,101 @@
package com.onixbyte.ahsarahguide.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
/**
* Entity representing a role assignment linking a user to a security role.
* <p>
* A user owns at most one role assignment, so the user ID is the primary key.
*
* @author zihluwang
*/
@Entity
@Table(name = "user_role")
public class UserRole {
@Id
@Column(name = "user_id")
private Long userId;
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_role_user"))
private User user;
@Column(name = "role", nullable = false, length = 32)
private String role;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
if (user != null) {
this.userId = user.getId();
}
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private User user;
private Long userId;
private String role;
public Builder user(User user) {
this.user = user;
return this;
}
public Builder userId(Long userId) {
this.userId = userId;
return this;
}
public Builder role(String role) {
this.role = role;
return this;
}
public UserRole build() {
UserRole userRole = new UserRole();
userRole.user = this.user;
userRole.role = this.role;
if (this.user != null) {
userRole.userId = this.user.getId();
}
if (this.userId != null) {
userRole.userId = this.userId;
}
return userRole;
}
}
}
@@ -0,0 +1,50 @@
package com.onixbyte.ahsarahguide.enumeration;
/**
* Enumeration of firearm types in the Delta Force game.
* Each type is associated with an integer code used for database persistence.
*
* @author zihluwang
*/
public enum FirearmType {
RIFLE(0),
SUB_MACHINE_GUN(1),
SHOTGUN(2),
LIGHT_MACHINE_GUN(3),
DESIGNATED_MARKSMAN_RIFLE(4),
SNIPER_RIFLE(5),
PISTOL(6),
SPECIAL(7);
private final int code;
FirearmType(int code) {
this.code = code;
}
public int getCode() {
return code;
}
/**
* Resolve a FirearmType from its integer code.
*
* @param code the integer code, may be null
* @return the corresponding FirearmType, or null if the code is null
* @throws IllegalArgumentException if the code does not match any known type
*/
public static FirearmType fromCode(Integer code) {
if (code == null) {
return null;
}
for (FirearmType type : values()) {
if (type.code == code) {
return type;
}
}
throw new IllegalArgumentException("Unknown FirearmType code: " + code);
}
}
@@ -0,0 +1,44 @@
package com.onixbyte.ahsarahguide.enumeration;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Enumeration of modification statuses.
* <p>
* Each status is associated with an integer code used for database persistence.
*
* @author zihluwang
*/
public enum ModificationStatus {
DRAFT(0),
PENDING_APPROVAL(1),
PUBLISHED(2);
private final int code;
ModificationStatus(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static final Map<Integer, ModificationStatus> CODE_MAP = Arrays.stream(values())
.collect(Collectors.toMap(ModificationStatus::getCode, Function.identity()));
/**
* Resolve a ModificationStatus from its integer code.
*
* @param code the integer code, may be null
* @return the corresponding ModificationStatus, or null if the code is null
* @throws IllegalArgumentException if the code does not match any known status
*/
public static ModificationStatus fromCode(Integer code) {
return CODE_MAP.get(code);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class BadRequestException extends ResponseStatusException {
public BadRequestException(String reason) {
super(HttpStatus.BAD_REQUEST, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class ConflictException extends ResponseStatusException {
public ConflictException(String reason) {
super(HttpStatus.CONFLICT, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class ForbiddenException extends ResponseStatusException {
public ForbiddenException(String reason) {
super(HttpStatus.FORBIDDEN, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class InternalServerErrorException extends ResponseStatusException {
public InternalServerErrorException(String reason) {
super(HttpStatus.INTERNAL_SERVER_ERROR, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class NotFoundException extends ResponseStatusException {
public NotFoundException(String reason) {
super(HttpStatus.NOT_FOUND, reason);
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class UnauthorisedException extends ResponseStatusException {
public UnauthorisedException(String reason) {
super(HttpStatus.UNAUTHORIZED, reason);
}
}
@@ -0,0 +1,123 @@
package com.onixbyte.ahsarahguide.filter;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.onixbyte.ahsarahguide.client.TokenClient;
import com.onixbyte.ahsarahguide.exeption.UnauthorisedException;
import com.onixbyte.ahsarahguide.manager.CookieManager;
import com.onixbyte.ahsarahguide.manager.UserManager;
import com.onixbyte.ahsarahguide.manager.UserRoleManager;
import com.onixbyte.ahsarahguide.security.authentication.UsernamePasswordAuthentication;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.util.WebUtils;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request.
*
* @author zihluwang
*/
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
private static final Duration ACCESS_TOKEN_RENEW_THRESHOLD = Duration.ofMinutes(5);
private final UserManager userManager;
private final UserRoleManager userRoleManager;
private final TokenClient tokenClient;
private final CookieManager cookieManager;
private final HandlerExceptionResolver handlerExceptionResolver;
public TokenAuthenticationFilter(
UserManager userManager,
UserRoleManager userRoleManager,
TokenClient tokenClient,
CookieManager cookieManager,
HandlerExceptionResolver handlerExceptionResolver
) {
this.userManager = userManager;
this.userRoleManager = userRoleManager;
this.tokenClient = tokenClient;
this.cookieManager = cookieManager;
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,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
var token = Optional.ofNullable(WebUtils.getCookie(request, "AccessToken"))
.map(Cookie::getValue)
.orElse(null);
if (Objects.isNull(token) || token.isBlank()) {
filterChain.doFilter(request, response);
return;
}
try {
var decodedToken = tokenClient.verifyToken(token);
var username = decodedToken.getSubject();
var userWrapper = userManager.findByUsername(username);
if (userWrapper.isEmpty()) {
throw new UnauthorisedException("登录已过期,请重新登录");
}
var user = userWrapper.get();
var roles = userRoleManager.findAllByUserId(user.getId());
var authorities = roles.stream()
.map(r -> new SimpleGrantedAuthority(r.getRole()))
.collect(Collectors.toList());
var authentication = UsernamePasswordAuthentication.authenticated(user, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
if (shouldRenew(decodedToken.getExpiresAt().toInstant())) {
var renewedToken = tokenClient.generateToken(user);
var renewedTokenCookie = cookieManager.buildCookie("AccessToken", renewedToken);
response.addHeader(HttpHeaders.SET_COOKIE, renewedTokenCookie.toString());
}
filterChain.doFilter(request, response);
} catch (JWTVerificationException e) {
log.error("JWT verification failed.", e);
handlerExceptionResolver.resolveException(request, response, null,
new UnauthorisedException("登录已过期,请重新登录"));
} catch (ResponseStatusException e) {
handlerExceptionResolver.resolveException(request, response, null, e);
}
}
private boolean shouldRenew(Instant expiresAt) {
return Duration.between(Instant.now(), expiresAt).compareTo(ACCESS_TOKEN_RENEW_THRESHOLD) < 0;
}
}
@@ -0,0 +1,22 @@
package com.onixbyte.ahsarahguide.filter;
import com.onixbyte.ahsarahguide.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.ahsarahguide.interceptor;
import com.onixbyte.crypto.util.CryptoUtil;
import com.onixbyte.ahsarahguide.exeption.InternalServerErrorException;
import com.onixbyte.ahsarahguide.exeption.UnauthorisedException;
import com.onixbyte.ahsarahguide.manager.WebhookManager;
import com.onixbyte.ahsarahguide.shared.GitHubWebhookHeader;
import com.onixbyte.ahsarahguide.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.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 InternalServerErrorException(
"Request body is not readable");
}
var secret = webhookManager.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 UnauthorisedException(
"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 UnauthorisedException("Invalid webhook signature");
}
return true;
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("Failed to compute HMAC-SHA256", e);
throw new InternalServerErrorException(
"Failed to verify webhook signature");
}
}
}
@@ -0,0 +1,46 @@
package com.onixbyte.ahsarahguide.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();
}
}
@@ -0,0 +1,27 @@
package com.onixbyte.ahsarahguide.manager;
import com.onixbyte.ahsarahguide.properties.AppProperties;
import org.springframework.stereotype.Component;
@Component
public class AppManager {
private final AppProperties appProperties;
public AppManager(AppProperties appProperties) {
this.appProperties = appProperties;
}
/**
* Retrieves the application version.
*
* @return the version string of this application
*/
public String getVersion() {
return "v%s-%s by @%s".formatted(
appProperties.version(),
appProperties.channel(),
appProperties.vendor()
);
}
}
@@ -0,0 +1,88 @@
package com.onixbyte.ahsarahguide.manager;
import com.onixbyte.ahsarahguide.properties.CookieProperties;
import org.springframework.boot.web.server.Cookie;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* Manager providing cookie construction operations with configurable properties.
*
* @author zihluwang
*/
@Component
public class CookieManager {
private final CookieProperties cookieProperties;
public CookieManager(CookieProperties cookieProperties) {
this.cookieProperties = cookieProperties;
}
public Boolean getHttpOnly() {
return cookieProperties.httpOnly();
}
public Boolean getSecure() {
return cookieProperties.secure();
}
public Cookie.SameSite getSameSite() {
return cookieProperties.sameSite();
}
public String getPath() {
return cookieProperties.path();
}
public Duration getMaxAge() {
return cookieProperties.maxAge();
}
/**
* 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, 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);
}
/**
* Creates a response cookie builder with specified name, value and valid duration.
*
* @param name name of the cookie
* @param value value of the cookie
* @param maxAge valid duration of the cookie
* @return cookie builder
*/
protected ResponseCookie buildCookieInternal(
String name,
String value,
Duration maxAge
) {
return ResponseCookie.from(name, value)
.secure(getSecure())
.maxAge(maxAge)
.httpOnly(getHttpOnly())
.path(getPath())
.sameSite(getSameSite().attributeValue())
.build();
}
}
@@ -0,0 +1,84 @@
package com.onixbyte.ahsarahguide.manager;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.onixbyte.ahsarahguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.ahsarahguide.exeption.InternalServerErrorException;
import com.onixbyte.ahsarahguide.shared.JacksonModules;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
/**
* Manager for daily password data access and caching coordination.
*
* @author zihluwang
*/
@Component
public class DailyPasswordManager {
private static final String CACHE_KEY_PREFIX = "daily-password:";
private final RestClient restClient;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public DailyPasswordManager(
RestClient.Builder restClientBuilder,
RedisTemplate<String, Object> redisTemplate
) {
var snakeCaseMapper = new ObjectMapper();
snakeCaseMapper.setPropertyNamingStrategy(
PropertyNamingStrategies.SnakeCaseStrategy.INSTANCE);
snakeCaseMapper.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
snakeCaseMapper.registerModule(JacksonModules.DATE_TIME_MODULE);
this.restClient = restClientBuilder
.baseUrl("https://tmini.net/api")
.messageConverters(converters -> {
converters.removeIf(
MappingJackson2HttpMessageConverter.class::isInstance);
converters.add(
new MappingJackson2HttpMessageConverter(snakeCaseMapper));
})
.build();
this.redisTemplate = redisTemplate;
}
/**
* 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();
var cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return (DailyPasswordResponse) cached;
}
var response = restClient.get()
.uri((uriBuilder) -> uriBuilder
.path("/sjzmm")
.queryParam("ckey", "")
.queryParam("type", "json")
.build())
.retrieve()
.body(DailyPasswordResponse.class);
if (Objects.isNull(response)) {
throw new InternalServerErrorException("暂无每日密码数据。");
}
redisTemplate.opsForValue().set(key, response, Duration.ofDays(1L));
return response;
}
}
@@ -0,0 +1,140 @@
package com.onixbyte.ahsarahguide.manager;
import com.onixbyte.ahsarahguide.domain.entity.Accessory;
import com.onixbyte.ahsarahguide.domain.entity.Firearm;
import com.onixbyte.ahsarahguide.domain.entity.Modification;
import com.onixbyte.ahsarahguide.domain.entity.Tuning;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.domain.dto.AccessoryRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationRequest;
import com.onixbyte.ahsarahguide.domain.dto.ModificationResponse;
import com.onixbyte.ahsarahguide.domain.dto.TuningRequest;
import com.onixbyte.ahsarahguide.repository.FirearmRepository;
import com.onixbyte.ahsarahguide.repository.ModificationRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
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, User user) {
var firearm = firearmRepository.findById(request.firearmId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"Firearm not found: " + request.firearmId()));
var modification = toEntity(request, firearm, user);
return ModificationResponse.from(modificationRepository.save(modification));
}
@Transactional
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests, User user) {
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()), user))
.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, User user) {
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())
.createBy(user == null ? null : user.getId())
.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;
}
@Transactional(readOnly = true)
public Page<Modification> findBySpec(Specification<Modification> spec, Pageable pageable) {
return modificationRepository.findAll(spec, pageable);
}
}
@@ -0,0 +1,76 @@
package com.onixbyte.ahsarahguide.manager;
import com.onixbyte.ahsarahguide.domain.entity.UserCredential;
import com.onixbyte.ahsarahguide.repository.UserCredentialRepository;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/**
* Manager for user credential persistence and authentication data access.
*
* @author zihluwang
*/
@Component
public class UserCredentialManager {
private final UserCredentialRepository userCredentialRepository;
public UserCredentialManager(UserCredentialRepository userCredentialRepository) {
this.userCredentialRepository = userCredentialRepository;
}
/**
* 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);
}
/**
* 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);
}
/**
* 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);
}
/**
* 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);
}
/**
* Deletes all credentials belonging to a user.
*
* @param userId the user ID
*/
public void deleteAllByUserId(Long userId) {
userCredentialRepository.deleteAllByUserId(userId);
}
}
@@ -0,0 +1,128 @@
package com.onixbyte.ahsarahguide.manager;
import com.onixbyte.ahsarahguide.domain.entity.User;
import com.onixbyte.ahsarahguide.domain.entity.UserRole;
import com.onixbyte.ahsarahguide.repository.UserRepository;
import com.onixbyte.ahsarahguide.repository.UserRoleRepository;
import org.springframework.stereotype.Component;
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 {
private final UserRepository userRepository;
private final UserRoleRepository userRoleRepository;
public UserManager(UserRepository userRepository, UserRoleRepository userRoleRepository) {
this.userRepository = userRepository;
this.userRoleRepository = userRoleRepository;
}
/**
* 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);
}
/**
* Retrieves all registered users.
* @return list of all users
*/
public List<User> findAll() {
return userRepository.findAll();
}
/**
* 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);
}
@Transactional(readOnly = true)
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
/**
* 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);
}
/**
* 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);
}
/**
* Checks whether a username is already registered.
*
* @param username the username to check
* @return true if the username is already taken
*/
public boolean existsByUsername(String username) {
return userRepository.existsByUsername(username);
}
/**
* Checks whether an email address is already registered.
*
* @param email the email address to check
* @return true if the email address is already taken
*/
public boolean existsByEmail(String email) {
return userRepository.existsByEmail(email);
}
/**
* Persists a new user together with an initial role assignment in a single transaction.
*
* @param user the user to persist, whose credentials are cascaded
* @param role the initial role name to assign
* @return the saved user
*/
@Transactional
public User createWithRole(User user, String role) {
var saved = userRepository.save(user);
userRoleRepository.save(UserRole.builder()
.user(saved)
.role(role)
.build());
return saved;
}
}
@@ -0,0 +1,72 @@
package com.onixbyte.ahsarahguide.manager;
import com.onixbyte.ahsarahguide.domain.entity.UserRole;
import com.onixbyte.ahsarahguide.repository.UserRoleRepository;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Manager for user role persistence and authorisation data access.
*
* @author zihluwang
*/
@Component
public class UserRoleManager {
private final UserRoleRepository userRoleRepository;
public UserRoleManager(UserRoleRepository userRoleRepository) {
this.userRoleRepository = userRoleRepository;
}
/**
* Finds all role assignments for a specific user.
*
* @param userId the user ID
* @return list of matching role assignments
*/
public List<UserRole> findAllByUserId(Long userId) {
return userRoleRepository.findAllByUserId(userId);
}
/**
* Checks whether a user has a specific role.
*
* @param userId the user ID
* @param role the role name
* @return true if the role is assigned, false otherwise
*/
public boolean existsByUserIdAndRole(Long userId, String role) {
return userRoleRepository.existsByUserIdAndRole(userId, role);
}
/**
* Persists a new role assignment.
*
* @param userRole the role assignment to save
* @return the saved role assignment
*/
public UserRole save(UserRole userRole) {
return userRoleRepository.save(userRole);
}
/**
* Deletes a role assignment for a specific user and role.
*
* @param userId the user ID
* @param role the role name
*/
public void deleteByUserIdAndRole(Long userId, String role) {
userRoleRepository.deleteByUserIdAndRole(userId, role);
}
/**
* Deletes all role assignments belonging to a user.
*
* @param userId the user ID
*/
public void deleteAllByUserId(Long userId) {
userRoleRepository.deleteAllByUserId(userId);
}
}
@@ -0,0 +1,24 @@
package com.onixbyte.ahsarahguide.manager;
import com.onixbyte.ahsarahguide.properties.GitHubWebhookProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class WebhookManager {
private final GitHubWebhookProperties gitHubWebhookProperties;
public WebhookManager(GitHubWebhookProperties gitHubWebhookProperties) {
this.gitHubWebhookProperties = gitHubWebhookProperties;
}
public String secret() {
return gitHubWebhookProperties.secret();
}
public List<String> allowedUsers() {
return gitHubWebhookProperties.allowedUsers();
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.ahsarahguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.common")
public record AppProperties(
String version,
String channel,
String vendor
) {
}
@@ -0,0 +1,27 @@
package com.onixbyte.ahsarahguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.web.server.Cookie;
import java.time.Duration;
/**
* 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,
@DefaultValue("true") Boolean secure,
@DefaultValue("/") String path,
@DefaultValue("PT2H") Duration maxAge,
@DefaultValue("LAX") Cookie.SameSite sameSite
) {
}
@@ -0,0 +1,36 @@
package com.onixbyte.ahsarahguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.http.HttpMethod;
import java.time.Duration;
/**
* 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"})
String[] allowedHeaders,
@DefaultValue({"GET", "POST", "PUT", "PATCH", "DELETE"})
HttpMethod[] allowedMethods,
String[] allowedOrigins,
boolean allowCredentials,
boolean allowPrivateNetwork,
@DefaultValue("PT2H")
Duration maxAge,
@DefaultValue({"Content-Type", "Authorization"})
String[] exposedHeaders
) {
}
@@ -0,0 +1,12 @@
package com.onixbyte.ahsarahguide.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
) {
}
@@ -0,0 +1,23 @@
package com.onixbyte.ahsarahguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
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,
String secret,
@DefaultValue("PT2H") Duration validTime,
@DefaultValue("PT5M") Duration renewThreshold
) {
}
@@ -0,0 +1,25 @@
package com.onixbyte.ahsarahguide.repository;
import com.onixbyte.ahsarahguide.domain.entity.Firearm;
import com.onixbyte.ahsarahguide.enumeration.FirearmType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
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>, JpaSpecificationExecutor<Firearm> {
Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
List<Firearm> findByName(String name);
}
@@ -0,0 +1,45 @@
package com.onixbyte.ahsarahguide.repository;
import com.onixbyte.ahsarahguide.domain.entity.Modification;
import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Spring Data JPA repository for {@link Modification} entity operations,
* including JSONB tag filtering for Postgres via the specification API.
*
* @author zihluwang
*/
@Repository
public interface ModificationRepository extends JpaRepository<Modification, Long>, JpaSpecificationExecutor<Modification> {
@Override
@EntityGraph(attributePaths = {"firearm"})
@NonNull
Page<Modification> findAll(Specification<Modification> spec, @NonNull Pageable pageable);
@Override
@EntityGraph(attributePaths = {"firearm"})
@NonNull
Optional<Modification> findById(@NonNull Long id);
/**
* 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);
}
@@ -0,0 +1,80 @@
package com.onixbyte.ahsarahguide.repository;
import com.onixbyte.ahsarahguide.domain.entity.UserCredential;
import com.onixbyte.ahsarahguide.domain.entity.UserCredentialId;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
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>,
JpaSpecificationExecutor<UserCredential> {
/**
* Find all credentials belonging to a given user.
*
* @param userId the user ID
* @return list of matching credentials
*/
@EntityGraph(attributePaths = {"user"})
@Query("""
select uc
from UserCredential uc
where uc.user.id = :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"})
@Query("""
select uc
from UserCredential uc
where uc.user.id = :userId
and uc.id.provider = :provider
""")
Optional<UserCredential> findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
/**
* 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
where uc.user.id = :userId
and uc.id.provider = :provider
""")
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
where uc.user.id = :userId
""")
void deleteAllByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,52 @@
package com.onixbyte.ahsarahguide.repository;
import com.onixbyte.ahsarahguide.domain.entity.User;
import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
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>, JpaSpecificationExecutor<User> {
@Override
@EntityGraph(attributePaths = {"credentials"})
@NonNull
Optional<User> findById(@NonNull Long id);
@EntityGraph(attributePaths = {"credentials"})
Optional<User> findByUsername(String username);
@EntityGraph(attributePaths = {"credentials"})
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
/**
* 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
from User u
where u.username = :principal
or u.email = :principal
""")
Optional<User> findByUsernameOrEmail(@Param("principal") String principal);
}
@@ -0,0 +1,72 @@
package com.onixbyte.ahsarahguide.repository;
import com.onixbyte.ahsarahguide.domain.entity.UserRole;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA repository for {@link UserRole} entity operations.
*
* @author zihluwang
*/
@Repository
public interface UserRoleRepository extends JpaRepository<UserRole, Long>, JpaSpecificationExecutor<UserRole> {
/**
* Find all roles assigned to a given user.
*
* @param userId the user ID
* @return list of matching role assignments
*/
@EntityGraph(attributePaths = {"user"})
@Query("""
select ur
from UserRole ur
where ur.user.id = :userId
""")
List<UserRole> findAllByUserId(@Param("userId") Long userId);
/**
* Check whether a user has a specific role.
*
* @param userId the user ID
* @param role the role name
* @return true if the role is assigned, false otherwise
*/
@Query("""
select count(ur) > 0
from UserRole ur
where ur.user.id = :userId
and ur.role = :role
""")
boolean existsByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role);
/**
* Delete a specific role assignment for a user.
*
* @param userId the user ID
* @param role the role name
*/
@Modifying
@Query("""
delete from UserRole ur
where ur.user.id = :userId
and ur.role = :role
""")
void deleteByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role);
/**
* Delete all role assignments for a given user.
*
* @param userId the user ID
*/
@Modifying
@Query("""
delete from UserRole ur
where ur.user.id = :userId
""")
void deleteAllByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,14 @@
package com.onixbyte.ahsarahguide.security.annotation;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated() && hasAnyRole('ADMIN', 'SUPER_USER')")
public @interface RequiresAdmin {
}
@@ -0,0 +1,19 @@
package com.onixbyte.ahsarahguide.security.annotation;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation to mark controller endpoints that require authentication.
*
* @author zihluwang
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated()")
public @interface RequiresAuth {
}
@@ -0,0 +1,14 @@
package com.onixbyte.ahsarahguide.security.annotation;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated() && hasRole('SUPER_USER')")
public @interface RequiresSuperUser {
}
@@ -0,0 +1,101 @@
package com.onixbyte.ahsarahguide.security.authentication;
import com.onixbyte.ahsarahguide.domain.entity.User;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.GrantedAuthority;
import java.util.ArrayList;
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;
private boolean authenticated;
private User user;
private Collection<? extends GrantedAuthority> authorities;
private UsernamePasswordAuthentication(
String username,
String password,
boolean authenticated,
User user,
Collection<? extends GrantedAuthority> authorities
) {
this.username = username;
this.password = password;
this.authenticated = authenticated;
this.user = user;
this.authorities = authorities == null ? List.of() : new ArrayList<>(authorities);
}
public static UsernamePasswordAuthentication unauthenticated(String username, String password) {
return new UsernamePasswordAuthentication(username, password, false, null, List.of());
}
public static UsernamePasswordAuthentication authenticated(User user) {
return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, List.of());
}
public static UsernamePasswordAuthentication authenticated(
User user,
Collection<? extends GrantedAuthority> authorities
) {
return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, authorities);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getCredentials() {
return password;
}
@Override
public User getDetails() {
return user;
}
@Override
public String getPrincipal() {
return username;
}
@Override
public boolean isAuthenticated() {
return authenticated;
}
@Override
public void setAuthenticated(boolean authenticated) throws IllegalArgumentException {
this.authenticated = authenticated;
}
@Override
public String getName() {
return username;
}
@Override
public void eraseCredentials() {
this.password = null;
}
public void setDetails(User user) {
this.user = user;
}
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = new ArrayList<>(authorities);
}
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more