From d9b74ae2e345070b495206f37570c8ce265e05be Mon Sep 17 00:00:00 2001 From: zihluwang Date: Wed, 19 Aug 2026 13:39:36 +0800 Subject: [PATCH] feat(auth): add email verification codes - Add POST /auth/verification-code endpoint to email a code - Store codes in Redis and verify them during registration - Send verification emails asynchronously via Thymeleaf - Replace email_verified column with code-based verification - Group dependencies into version catalogue bundles --- build.gradle.kts | 51 +++------ gradle/libs.versions.toml | 88 +++++++++++++++ .../DeltaForceGuideApplication.java | 2 + .../deltaforceguide/client/EmailClient.java | 105 ++++++++++++++++++ .../config/EmailTemplateConfig.java | 37 ++++++ .../controller/AuthController.java | 11 +- .../domain/dto/RegisterRequest.java | 4 +- .../dto/SendVerificationCodeRequest.java | 9 ++ .../dto/SendVerificationCodeResponse.java | 6 + .../domain/dto/UpdateProfileRequest.java | 6 +- .../domain/dto/UserProfileResponse.java | 2 - .../deltaforceguide/domain/entity/User.java | 18 --- .../exeption/ConflictException.java | 11 ++ .../repository/BaseRepository.java | 7 -- .../repository/FirearmRepository.java | 3 +- .../repository/ModificationRepository.java | 3 +- .../repository/UserRepository.java | 3 +- .../repository/UserRoleRepository.java | 7 +- .../deltaforceguide/service/AuthService.java | 69 +++++++++++- .../deltaforceguide/service/UserService.java | 5 +- src/main/resources/application.yaml | 4 + .../db/migration/V6__user_profile.sql | 1 - .../mail-templates/verification-code.html | 71 ++++++++++++ 23 files changed, 440 insertions(+), 83 deletions(-) create mode 100644 src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java create mode 100644 src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java delete mode 100644 src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java create mode 100644 src/main/resources/mail-templates/verification-code.html diff --git a/build.gradle.kts b/build.gradle.kts index 8e62389..7dac164 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,43 +24,28 @@ java { } dependencies { - implementation(libs.jspecify.core) + // BOMs are imported as platforms, so they cannot be bundled. implementation(platform(libs.aws.sdk.bom)) - implementation(libs.aws.sdk.s3) - implementation(libs.commons.io) - implementation(libs.commons.lang) - implementation(libs.commons.collections) - implementation(libs.hypersistence.core) implementation(platform(libs.onixbyte.versionCatalogue)) - implementation(libs.onixbyte.tuple) - implementation(libs.onixbyte.commonToolbox) - implementation(libs.onixbyte.mathToolbox) - implementation(libs.onixbyte.identityGenerator) - implementation(libs.onixbyte.captcha) - implementation(libs.onixbyte.regions) - implementation(libs.onixbyte.cryptoToolbox) - implementation(libs.jwt.core) + + implementation(libs.bundles.utilities) + implementation(libs.bundles.onixbyte) + implementation(libs.bundles.web) + implementation(libs.bundles.persistence) + implementation(libs.bundles.security) + implementation(libs.bundles.cache) + implementation(libs.bundles.observability) + implementation(libs.bundles.mail) + implementation(libs.bundles.aws) + implementation(libs.bundles.docs) + + // Annotation processor for @ConfigurationProperties; kept out of bundles. implementation(libs.spring.boot.configurationProcessor) - implementation(libs.spring.boot.actuator) - implementation(libs.spring.boot.starter.web) - implementation(libs.spring.boot.starter.webFlux) - implementation(libs.spring.boot.starter.validation) - implementation(libs.spring.boot.starter.redis) - implementation(libs.spring.boot.starter.cache) - implementation(libs.spring.boot.starter.jpa) - implementation(libs.mybatis.starter.core) - implementation(libs.flyway.core) - implementation(libs.flyway.postgresql) - implementation(libs.jackson.jsr310) - implementation(libs.spring.boot.starter.doc) - implementation(libs.spring.boot.starter.security) - testImplementation(libs.spring.boot.starter.test) - testImplementation(libs.reactor.test) - testImplementation(libs.mybatis.starter.test) - testImplementation(libs.spring.security.test) + runtimeOnly(libs.postgres.driver) - testRuntimeOnly(libs.h2.database) - testRuntimeOnly(libs.junit.launcher) + + testImplementation(libs.bundles.testing) + testRuntimeOnly(libs.bundles.testRuntime) } tasks.processResources { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9650f6..a9ff6c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,11 @@ spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" } jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" } +# Java Mail & Templating +spring-boot-starter-mail = { module = "org.springframework.boot:spring-boot-starter-mail" } +spring-boot-starter-thymeleaf = { group = "org.springframework.boot", name = "spring-boot-starter-thymeleaf" } +thymeleaf-extras-springsecurity6 = { group = "org.thymeleaf.extras", name = "thymeleaf-extras-springsecurity6" } + # Spring Doc spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" } @@ -73,6 +78,89 @@ reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" } mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" } +[bundles] +# General utilities +utilities = [ + "jspecify-core", + "commons-io", + "commons-lang", + "commons-collections", + "jackson-jsr310", +] + +# Onixbyte ecosystem (the version-catalogue BOM stays out of bundles, it is imported as a platform) +onixbyte = [ + "onixbyte-tuple", + "onixbyte-commonToolbox", + "onixbyte-cryptoToolbox", + "onixbyte-mathToolbox", + "onixbyte-identityGenerator", + "onixbyte-captcha", + "onixbyte-regions", +] + +# Web layer +web = [ + "spring-boot-starter-web", + "spring-boot-starter-webFlux", + "spring-boot-starter-validation", +] + +# Persistence and database +persistence = [ + "spring-boot-starter-jpa", + "mybatis-starter-core", + "hypersistence-core", + "flyway-core", + "flyway-postgresql", +] + +# Security and authentication +security = [ + "spring-boot-starter-security", + "jwt-core", +] + +# Caching (Redis) +cache = [ + "spring-boot-starter-redis", + "spring-boot-starter-cache", +] + +# Observability +observability = [ + "spring-boot-actuator", +] + +# Mail and templating +mail = [ + "spring-boot-starter-mail", + "spring-boot-starter-thymeleaf", + "thymeleaf-extras-springsecurity6", +] + +# AWS cloud services +aws = [ + "aws-sdk-s3", +] + +# API documentation +docs = [ + "spring-boot-starter-doc", +] + +# Testing +testing = [ + "spring-boot-starter-test", + "spring-security-test", + "reactor-test", + "mybatis-starter-test", +] +testRuntime = [ + "h2-database", + "junit-launcher", +] + [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" } diff --git a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java index a23fb88..c500f7d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java @@ -2,12 +2,14 @@ package com.onixbyte.deltaforceguide; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; /** * Entry point for the Delta Force Guide Server application. * * @author zihluwang */ +@EnableAsync @SpringBootApplication public class DeltaForceGuideApplication { diff --git a/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java b/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java new file mode 100644 index 0000000..52a772f --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/client/EmailClient.java @@ -0,0 +1,105 @@ +package com.onixbyte.deltaforceguide.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; + + @Value("${spring.mail.username}") + private String fromAddress; + + @Autowired + public EmailClient( + JavaMailSender mailSender, + TemplateEngine emailTemplateEngine + ) { + this.mailSender = mailSender; + this.emailTemplateEngine = emailTemplateEngine; + } + + /** + * 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 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.of( + "username", username, + "code", code, + "expireMinutes", expireMinutes + ); + + sendTemplateMail(toRecipient, subject, "verification-code", variables); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java new file mode 100644 index 0000000..cf713eb --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/EmailTemplateConfig.java @@ -0,0 +1,37 @@ +package com.onixbyte.deltaforceguide.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(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index e1071ed..c88c4bd 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -1,8 +1,6 @@ package com.onixbyte.deltaforceguide.controller; -import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; -import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; -import com.onixbyte.deltaforceguide.domain.dto.UserResponse; +import com.onixbyte.deltaforceguide.domain.dto.*; import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.service.AuthService; import io.swagger.v3.oas.annotations.Operation; @@ -48,6 +46,13 @@ public class AuthController { .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 register(@Validated @RequestBody RegisterRequest request) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java index 92b8040..fe314f4 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/RegisterRequest.java @@ -24,6 +24,8 @@ public record RegisterRequest( String avatarUrl, @NotBlank(message = "密码不能为空") @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) - String password + String password, + @NotBlank(message = "验证码 ID 不能为空") String verificationCodeId, + @NotBlank(message = "验证码不能为空") String verificationCode ) { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java new file mode 100644 index 0000000..e2eb2f4 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeRequest.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SendVerificationCodeRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "电子邮箱地址不能为空") String email +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java new file mode 100644 index 0000000..2bc9c51 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/SendVerificationCodeResponse.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +public record SendVerificationCodeResponse( + String verificationCodeId +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java index 0d1b016..211e3b3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java @@ -10,9 +10,5 @@ import jakarta.validation.constraints.Size; */ public record UpdateProfileRequest( @Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间") - String nickname, - - @Email(message = "请输入正确的电子邮箱地址") - @Size(max = 255, message = "电子邮箱长度不能超过255个字符") - String email + String nickname ) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java index 5609119..7e40211 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java @@ -16,7 +16,6 @@ public record UserProfileResponse( String nickname, String email, String avatarUrl, - Boolean emailVerified, Boolean enabled, LocalDateTime createdAt, List roles @@ -37,7 +36,6 @@ public record UserProfileResponse( ? user.getUsername() : user.getNickname(), user.getEmail(), user.getAvatarUrl(), - user.getEmailVerified(), user.getEnabled(), user.getCreatedAt(), roles diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java index c061c09..de7cad5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -39,9 +39,6 @@ public class User { @Column(name = "avatar_url") private String avatarUrl; - @Column(name = "email_verified", nullable = false) - private Boolean emailVerified = false; - @Column(name = "enabled", nullable = false) private Boolean enabled = true; @@ -94,14 +91,6 @@ public class User { this.avatarUrl = avatarUrl; } - public Boolean getEmailVerified() { - return emailVerified; - } - - public void setEmailVerified(Boolean emailVerified) { - this.emailVerified = emailVerified; - } - public Boolean getEnabled() { return enabled; } @@ -160,7 +149,6 @@ public class User { private String email; private String nickname; private String avatarUrl; - private Boolean emailVerified; private Boolean enabled; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -191,11 +179,6 @@ public class User { return this; } - public Builder emailVerified(Boolean emailVerified) { - this.emailVerified = emailVerified; - return this; - } - public Builder enabled(Boolean enabled) { this.enabled = enabled; return this; @@ -224,7 +207,6 @@ public class User { user.nickname = this.nickname == null || this.nickname.isBlank() ? this.username : this.nickname; user.avatarUrl = this.avatarUrl; - user.emailVerified = this.emailVerified != null && this.emailVerified; user.enabled = this.enabled == null || this.enabled; user.createdAt = this.createdAt; user.updatedAt = this.updatedAt; diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java new file mode 100644 index 0000000..971740f --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/ConflictException.java @@ -0,0 +1,11 @@ +package com.onixbyte.deltaforceguide.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); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java deleted file mode 100644 index 93ab920..0000000 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/BaseRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.onixbyte.deltaforceguide.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; - -public interface BaseRepository extends JpaRepository, JpaSpecificationExecutor { -} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index 881ee97..0e51b9c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.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; @@ -15,7 +16,7 @@ import java.util.List; * @author zihluwang */ @Repository -public interface FirearmRepository extends BaseRepository { +public interface FirearmRepository extends JpaRepository, JpaSpecificationExecutor { Page findAllByType(FirearmType type, Pageable pageable); diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index bd16813..73173f9 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -20,7 +21,7 @@ import java.util.Optional; * @author zihluwang */ @Repository -public interface ModificationRepository extends BaseRepository { +public interface ModificationRepository extends JpaRepository, JpaSpecificationExecutor { @EntityGraph(attributePaths = {"firearm"}) Page findAllBy(Pageable pageable); diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java index cf81ca8..dad3df7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRepository.java @@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.User; import org.jspecify.annotations.NonNull; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -16,7 +17,7 @@ import java.util.Optional; * @author zihluwang */ @Repository -public interface UserRepository extends BaseRepository { +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { @Override @EntityGraph(attributePaths = {"credentials"}) diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java index a227b72..16f0792 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java @@ -2,10 +2,7 @@ package com.onixbyte.deltaforceguide.repository; import com.onixbyte.deltaforceguide.domain.entity.UserRole; import com.onixbyte.deltaforceguide.domain.entity.UserRoleId; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -17,7 +14,7 @@ import java.util.List; * @author zihluwang */ @Repository -public interface UserRoleRepository extends BaseRepository { +public interface UserRoleRepository extends JpaRepository, JpaSpecificationExecutor { /** * Find all roles assigned to a given user. diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index 82ffbb0..4349d36 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -1,12 +1,16 @@ package com.onixbyte.deltaforceguide.service; +import com.onixbyte.captcha.text.TextProducer; +import com.onixbyte.deltaforceguide.client.EmailClient; import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; +import com.onixbyte.deltaforceguide.domain.dto.SendVerificationCodeRequest; import com.onixbyte.deltaforceguide.domain.dto.UserResponse; import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.exeption.BadRequestException; +import com.onixbyte.deltaforceguide.exeption.ConflictException; import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.manager.CookieManager; import com.onixbyte.deltaforceguide.manager.UserManager; @@ -17,6 +21,8 @@ import com.onixbyte.deltaforceguide.shared.Role; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -26,6 +32,7 @@ import org.springframework.stereotype.Service; import java.time.Duration; import java.time.LocalDateTime; +import java.util.UUID; /** * Service handling user authentication, login, and session management. @@ -41,19 +48,31 @@ public class AuthService { private final CookieManager cookieManager; private final UserManager userManager; private final PasswordEncoder passwordEncoder; + private final TextProducer textProducer; + private final CacheManager cacheManager; + private final RedisTemplate redisTemplate; + private final EmailClient emailClient; public AuthService( AuthenticationManager authenticationManager, TokenClient tokenClient, CookieManager cookieManager, UserManager userManager, - PasswordEncoder passwordEncoder + PasswordEncoder passwordEncoder, + TextProducer textProducer, + CacheManager cacheManager, + RedisTemplate redisTemplate, + EmailClient emailClient ) { this.authenticationManager = authenticationManager; this.tokenClient = tokenClient; this.cookieManager = cookieManager; this.userManager = userManager; this.passwordEncoder = passwordEncoder; + this.textProducer = textProducer; + this.cacheManager = cacheManager; + this.redisTemplate = redisTemplate; + this.emailClient = emailClient; } /** @@ -96,6 +115,19 @@ public class AuthService { throw new BadRequestException("该邮箱已被注册。"); } + var code = request.verificationCode(); + var verificationCodeId = request.verificationCodeId(); + + var storedCode = redisTemplate.opsForValue().get("verification-code:" + verificationCodeId); + if (!(storedCode instanceof String strStoredCode)) { + log.error("storedCode not a String, storedCode={}", storedCode); + throw new InternalServerErrorException("系统内部错误,请重试"); + } + + if (!code.equalsIgnoreCase(strStoredCode)) { + throw new BadRequestException("邮箱验证码不正确,请重试。"); + } + var now = LocalDateTime.now(); var user = User.builder() .username(username) @@ -129,4 +161,39 @@ public class AuthService { public HttpCookie logout() { return cookieManager.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO); } + + /** + * Generates and dispatches a verification code to the given email address. + *

+ * The code is cached under the returned UUID for 10 minutes and emailed + * asynchronously to the recipient. The UUID must be supplied back to the + * server when verifying the code. + * + * @param request the verification code request containing the target username and email + * @return the UUID identifying the verification record, to be used when verifying the code + * @throws ConflictException if the username or email is already registered + */ + public String sendVerificationCode(SendVerificationCodeRequest request) { + // Check username. + if (userManager.existsByUsername(request.username())) { + throw new ConflictException("用户名【" + request.username() + "】已被使用。"); + } + + // Check email address. + if (userManager.existsByEmail(request.email())) { + throw new ConflictException("邮箱地址【" + request.email() + "】已被使用。"); + } + + // Generate UUID and verification code. + var uuid = UUID.randomUUID().toString().replace("-", ""); + var code = textProducer.getText(); + + // Save verification code to cache. + redisTemplate.opsForValue().set("verification-code:" + uuid, code, Duration.ofMinutes(10L)); + + // Send email. + emailClient.sendVerificationCode(request.email(), request.username(), code, 10); + + return uuid; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java index cabefe7..d9b9043 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -196,10 +196,7 @@ public class UserService { if (StringUtils.hasText(request.nickname())) { user.setNickname(request.nickname()); } - if (StringUtils.hasText(request.email()) && !Objects.equals(user.getEmail(), request.email())) { - user.setEmail(request.email()); - user.setEmailVerified(false); - } + userManager.save(user); return getProfile(user); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8fcdf2f..cf3b3e8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -30,6 +30,8 @@ spring: flyway: enabled: true baseline-on-migrate: true + thymeleaf: + enabled: false mybatis: configuration: @@ -40,6 +42,8 @@ mybatis: mapper-locations: classpath:/mapper/*.xml logging: + pattern: + dateformat: dd MMM yyyy HH:mm:ss level: org.hibernate: orm.connections.pooling: off diff --git a/src/main/resources/db/migration/V6__user_profile.sql b/src/main/resources/db/migration/V6__user_profile.sql index 6bb1ff8..4a4dbc5 100644 --- a/src/main/resources/db/migration/V6__user_profile.sql +++ b/src/main/resources/db/migration/V6__user_profile.sql @@ -1,7 +1,6 @@ ALTER TABLE app_user ADD COLUMN nickname VARCHAR(64), ADD COLUMN avatar_url VARCHAR(512), - ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE, ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); diff --git a/src/main/resources/mail-templates/verification-code.html b/src/main/resources/mail-templates/verification-code.html new file mode 100644 index 0000000..d6e9c37 --- /dev/null +++ b/src/main/resources/mail-templates/verification-code.html @@ -0,0 +1,71 @@ + + + + + + 账号验证 + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+

账号注册验证

+
+
+
+

+ 您好 用户, +

+

+ 感谢注册我们的服务。请使用下方的验证码完成账号验证,该验证码在 10 分钟 内有效: +

+ + + + + + +
+ 123456 +
+ +

+ 若您未发起此操作或未尝试注册该账号,请直接忽略本邮件。 +

+
+

+ 此邮件为系统自动发送,请勿直接回复。 +

+

+ © 2026 阿萨拉向导. 保留所有权利。 +

+
+
+ + \ No newline at end of file