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
This commit is contained in:
23 files changed
+440
-83
No files matched your search
+18
-33
@@ -24,43 +24,28 @@ java {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.jspecify.core)
|
// BOMs are imported as platforms, so they cannot be bundled.
|
||||||
implementation(platform(libs.aws.sdk.bom))
|
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(platform(libs.onixbyte.versionCatalogue))
|
||||||
implementation(libs.onixbyte.tuple)
|
|
||||||
implementation(libs.onixbyte.commonToolbox)
|
implementation(libs.bundles.utilities)
|
||||||
implementation(libs.onixbyte.mathToolbox)
|
implementation(libs.bundles.onixbyte)
|
||||||
implementation(libs.onixbyte.identityGenerator)
|
implementation(libs.bundles.web)
|
||||||
implementation(libs.onixbyte.captcha)
|
implementation(libs.bundles.persistence)
|
||||||
implementation(libs.onixbyte.regions)
|
implementation(libs.bundles.security)
|
||||||
implementation(libs.onixbyte.cryptoToolbox)
|
implementation(libs.bundles.cache)
|
||||||
implementation(libs.jwt.core)
|
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.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)
|
runtimeOnly(libs.postgres.driver)
|
||||||
testRuntimeOnly(libs.h2.database)
|
|
||||||
testRuntimeOnly(libs.junit.launcher)
|
testImplementation(libs.bundles.testing)
|
||||||
|
testRuntimeOnly(libs.bundles.testRuntime)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.processResources {
|
tasks.processResources {
|
||||||
|
|||||||
@@ -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" }
|
spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" }
|
||||||
jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" }
|
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 Doc
|
||||||
spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" }
|
spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" }
|
||||||
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" }
|
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" }
|
||||||
@@ -2,12 +2,14 @@ package com.onixbyte.deltaforceguide;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point for the Delta Force Guide Server application.
|
* Entry point for the Delta Force Guide Server application.
|
||||||
*
|
*
|
||||||
* @author zihluwang
|
* @author zihluwang
|
||||||
*/
|
*/
|
||||||
|
@EnableAsync
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class DeltaForceGuideApplication {
|
public class DeltaForceGuideApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -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<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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package com.onixbyte.deltaforceguide.controller;
|
package com.onixbyte.deltaforceguide.controller;
|
||||||
|
|
||||||
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
|
import com.onixbyte.deltaforceguide.domain.dto.*;
|
||||||
import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest;
|
|
||||||
import com.onixbyte.deltaforceguide.domain.dto.UserResponse;
|
|
||||||
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
|
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
|
||||||
import com.onixbyte.deltaforceguide.service.AuthService;
|
import com.onixbyte.deltaforceguide.service.AuthService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -48,6 +46,13 @@ public class AuthController {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(description = "发送邮件验证码")
|
||||||
|
@PostMapping("/verification-code")
|
||||||
|
public SendVerificationCodeResponse sendVerificationCode(@Validated @RequestBody SendVerificationCodeRequest request) {
|
||||||
|
var verificationCodeId = authService.sendVerificationCode(request);
|
||||||
|
return new SendVerificationCodeResponse(verificationCodeId);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(description = "用户注册")
|
@Operation(description = "用户注册")
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<UserResponse> register(@Validated @RequestBody RegisterRequest request) {
|
public ResponseEntity<UserResponse> register(@Validated @RequestBody RegisterRequest request) {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public record RegisterRequest(
|
|||||||
String avatarUrl,
|
String avatarUrl,
|
||||||
@NotBlank(message = "密码不能为空")
|
@NotBlank(message = "密码不能为空")
|
||||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
String password
|
String password,
|
||||||
|
@NotBlank(message = "验证码 ID 不能为空") String verificationCodeId,
|
||||||
|
@NotBlank(message = "验证码不能为空") String verificationCode
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.domain.dto;
|
||||||
|
|
||||||
|
public record SendVerificationCodeResponse(
|
||||||
|
String verificationCodeId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -10,9 +10,5 @@ import jakarta.validation.constraints.Size;
|
|||||||
*/
|
*/
|
||||||
public record UpdateProfileRequest(
|
public record UpdateProfileRequest(
|
||||||
@Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间")
|
@Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间")
|
||||||
String nickname,
|
String nickname
|
||||||
|
|
||||||
@Email(message = "请输入正确的电子邮箱地址")
|
|
||||||
@Size(max = 255, message = "电子邮箱长度不能超过255个字符")
|
|
||||||
String email
|
|
||||||
) {}
|
) {}
|
||||||
@@ -16,7 +16,6 @@ public record UserProfileResponse(
|
|||||||
String nickname,
|
String nickname,
|
||||||
String email,
|
String email,
|
||||||
String avatarUrl,
|
String avatarUrl,
|
||||||
Boolean emailVerified,
|
|
||||||
Boolean enabled,
|
Boolean enabled,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
List<String> roles
|
List<String> roles
|
||||||
@@ -37,7 +36,6 @@ public record UserProfileResponse(
|
|||||||
? user.getUsername() : user.getNickname(),
|
? user.getUsername() : user.getNickname(),
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
user.getAvatarUrl(),
|
user.getAvatarUrl(),
|
||||||
user.getEmailVerified(),
|
|
||||||
user.getEnabled(),
|
user.getEnabled(),
|
||||||
user.getCreatedAt(),
|
user.getCreatedAt(),
|
||||||
roles
|
roles
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ public class User {
|
|||||||
@Column(name = "avatar_url")
|
@Column(name = "avatar_url")
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
|
|
||||||
@Column(name = "email_verified", nullable = false)
|
|
||||||
private Boolean emailVerified = false;
|
|
||||||
|
|
||||||
@Column(name = "enabled", nullable = false)
|
@Column(name = "enabled", nullable = false)
|
||||||
private Boolean enabled = true;
|
private Boolean enabled = true;
|
||||||
|
|
||||||
@@ -94,14 +91,6 @@ public class User {
|
|||||||
this.avatarUrl = avatarUrl;
|
this.avatarUrl = avatarUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Boolean getEmailVerified() {
|
|
||||||
return emailVerified;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEmailVerified(Boolean emailVerified) {
|
|
||||||
this.emailVerified = emailVerified;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Boolean getEnabled() {
|
public Boolean getEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
@@ -160,7 +149,6 @@ public class User {
|
|||||||
private String email;
|
private String email;
|
||||||
private String nickname;
|
private String nickname;
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
private Boolean emailVerified;
|
|
||||||
private Boolean enabled;
|
private Boolean enabled;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
@@ -191,11 +179,6 @@ public class User {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Builder emailVerified(Boolean emailVerified) {
|
|
||||||
this.emailVerified = emailVerified;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder enabled(Boolean enabled) {
|
public Builder enabled(Boolean enabled) {
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
return this;
|
return this;
|
||||||
@@ -224,7 +207,6 @@ public class User {
|
|||||||
user.nickname = this.nickname == null || this.nickname.isBlank()
|
user.nickname = this.nickname == null || this.nickname.isBlank()
|
||||||
? this.username : this.nickname;
|
? this.username : this.nickname;
|
||||||
user.avatarUrl = this.avatarUrl;
|
user.avatarUrl = this.avatarUrl;
|
||||||
user.emailVerified = this.emailVerified != null && this.emailVerified;
|
|
||||||
user.enabled = this.enabled == null || this.enabled;
|
user.enabled = this.enabled == null || this.enabled;
|
||||||
user.createdAt = this.createdAt;
|
user.createdAt = this.createdAt;
|
||||||
user.updatedAt = this.updatedAt;
|
user.updatedAt = this.updatedAt;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T, K> extends JpaRepository<T, K>, JpaSpecificationExecutor<T> {
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -15,7 +16,7 @@ import java.util.List;
|
|||||||
* @author zihluwang
|
* @author zihluwang
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface FirearmRepository extends BaseRepository<Firearm, Long> {
|
public interface FirearmRepository extends JpaRepository<Firearm, Long>, JpaSpecificationExecutor<Firearm> {
|
||||||
|
|
||||||
Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
|
Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.EntityGraph;
|
import org.springframework.data.jpa.repository.EntityGraph;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -20,7 +21,7 @@ import java.util.Optional;
|
|||||||
* @author zihluwang
|
* @author zihluwang
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface ModificationRepository extends BaseRepository<Modification, Long> {
|
public interface ModificationRepository extends JpaRepository<Modification, Long>, JpaSpecificationExecutor<Modification> {
|
||||||
|
|
||||||
@EntityGraph(attributePaths = {"firearm"})
|
@EntityGraph(attributePaths = {"firearm"})
|
||||||
Page<Modification> findAllBy(Pageable pageable);
|
Page<Modification> findAllBy(Pageable pageable);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.User;
|
|||||||
import org.jspecify.annotations.NonNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.springframework.data.jpa.repository.EntityGraph;
|
import org.springframework.data.jpa.repository.EntityGraph;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -16,7 +17,7 @@ import java.util.Optional;
|
|||||||
* @author zihluwang
|
* @author zihluwang
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserRepository extends BaseRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@EntityGraph(attributePaths = {"credentials"})
|
@EntityGraph(attributePaths = {"credentials"})
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package com.onixbyte.deltaforceguide.repository;
|
|||||||
|
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.UserRole;
|
import com.onixbyte.deltaforceguide.domain.entity.UserRole;
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.UserRoleId;
|
import com.onixbyte.deltaforceguide.domain.entity.UserRoleId;
|
||||||
import org.springframework.data.jpa.repository.EntityGraph;
|
import org.springframework.data.jpa.repository.*;
|
||||||
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.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -17,7 +14,7 @@ import java.util.List;
|
|||||||
* @author zihluwang
|
* @author zihluwang
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserRoleRepository extends BaseRepository<UserRole, UserRoleId> {
|
public interface UserRoleRepository extends JpaRepository<UserRole, UserRoleId>, JpaSpecificationExecutor<UserRole> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all roles assigned to a given user.
|
* Find all roles assigned to a given user.
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.onixbyte.deltaforceguide.service;
|
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.client.TokenClient;
|
||||||
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
|
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
|
||||||
import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest;
|
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.dto.UserResponse;
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.User;
|
import com.onixbyte.deltaforceguide.domain.entity.User;
|
||||||
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
|
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
|
||||||
import com.onixbyte.deltaforceguide.exeption.BadRequestException;
|
import com.onixbyte.deltaforceguide.exeption.BadRequestException;
|
||||||
|
import com.onixbyte.deltaforceguide.exeption.ConflictException;
|
||||||
import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException;
|
import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException;
|
||||||
import com.onixbyte.deltaforceguide.manager.CookieManager;
|
import com.onixbyte.deltaforceguide.manager.CookieManager;
|
||||||
import com.onixbyte.deltaforceguide.manager.UserManager;
|
import com.onixbyte.deltaforceguide.manager.UserManager;
|
||||||
@@ -17,6 +21,8 @@ import com.onixbyte.deltaforceguide.shared.Role;
|
|||||||
import org.jspecify.annotations.NonNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.HttpCookie;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -26,6 +32,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service handling user authentication, login, and session management.
|
* Service handling user authentication, login, and session management.
|
||||||
@@ -41,19 +48,31 @@ public class AuthService {
|
|||||||
private final CookieManager cookieManager;
|
private final CookieManager cookieManager;
|
||||||
private final UserManager userManager;
|
private final UserManager userManager;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final TextProducer textProducer;
|
||||||
|
private final CacheManager cacheManager;
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
private final EmailClient emailClient;
|
||||||
|
|
||||||
public AuthService(
|
public AuthService(
|
||||||
AuthenticationManager authenticationManager,
|
AuthenticationManager authenticationManager,
|
||||||
TokenClient tokenClient,
|
TokenClient tokenClient,
|
||||||
CookieManager cookieManager,
|
CookieManager cookieManager,
|
||||||
UserManager userManager,
|
UserManager userManager,
|
||||||
PasswordEncoder passwordEncoder
|
PasswordEncoder passwordEncoder,
|
||||||
|
TextProducer textProducer,
|
||||||
|
CacheManager cacheManager,
|
||||||
|
RedisTemplate<String, Object> redisTemplate,
|
||||||
|
EmailClient emailClient
|
||||||
) {
|
) {
|
||||||
this.authenticationManager = authenticationManager;
|
this.authenticationManager = authenticationManager;
|
||||||
this.tokenClient = tokenClient;
|
this.tokenClient = tokenClient;
|
||||||
this.cookieManager = cookieManager;
|
this.cookieManager = cookieManager;
|
||||||
this.userManager = userManager;
|
this.userManager = userManager;
|
||||||
this.passwordEncoder = passwordEncoder;
|
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("该邮箱已被注册。");
|
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 now = LocalDateTime.now();
|
||||||
var user = User.builder()
|
var user = User.builder()
|
||||||
.username(username)
|
.username(username)
|
||||||
@@ -129,4 +161,39 @@ public class AuthService {
|
|||||||
public HttpCookie logout() {
|
public HttpCookie logout() {
|
||||||
return cookieManager.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO);
|
return cookieManager.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and dispatches a verification code to the given email address.
|
||||||
|
* <p>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -196,10 +196,7 @@ public class UserService {
|
|||||||
if (StringUtils.hasText(request.nickname())) {
|
if (StringUtils.hasText(request.nickname())) {
|
||||||
user.setNickname(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);
|
userManager.save(user);
|
||||||
return getProfile(user);
|
return getProfile(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ spring:
|
|||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
|
thymeleaf:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
mybatis:
|
mybatis:
|
||||||
configuration:
|
configuration:
|
||||||
@@ -40,6 +42,8 @@ mybatis:
|
|||||||
mapper-locations: classpath:/mapper/*.xml
|
mapper-locations: classpath:/mapper/*.xml
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
pattern:
|
||||||
|
dateformat: dd MMM yyyy HH:mm:ss
|
||||||
level:
|
level:
|
||||||
org.hibernate:
|
org.hibernate:
|
||||||
orm.connections.pooling: off
|
orm.connections.pooling: off
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
ALTER TABLE app_user
|
ALTER TABLE app_user
|
||||||
ADD COLUMN nickname VARCHAR(64),
|
ADD COLUMN nickname VARCHAR(64),
|
||||||
ADD COLUMN avatar_url VARCHAR(512),
|
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 enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>账号验证</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 40px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!-- 主容器 -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 520px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06); border: 1px solid #e2e8f0;">
|
||||||
|
|
||||||
|
<!-- 顶部标题 -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 36px 32px 24px; background-color: #ffffff;">
|
||||||
|
<h1 style="margin: 0; font-size: 22px; font-weight: 700; color: #0f172a; letter-spacing: -0.3px;" th:text="${appName != null ? appName : '账号注册验证'}">账号注册验证</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- 分割线 -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0 32px;">
|
||||||
|
<div style="height: 1px; background-color: #f1f5f9; width: 100%;"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- 正文内容 -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px 32px 24px;">
|
||||||
|
<p style="margin: 0 0 16px; font-size: 15px; line-height: 1.6; color: #334155;">
|
||||||
|
您好 <strong th:text="${username}">用户</strong>,
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 24px; font-size: 14px; line-height: 1.6; color: #64748b;">
|
||||||
|
感谢注册我们的服务。请使用下方的验证码完成账号验证,该验证码在 <strong style="color: #334155;"><span th:text="${expireMinutes}">10</span> 分钟</strong> 内有效:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 验证码展示区域 -->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin: 8px 0 24px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 20px; background-color: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px;">
|
||||||
|
<span style="font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 32px; font-weight: 700; color: #2563eb; letter-spacing: 8px; margin-left: 8px;" th:text="${code}">123456</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.6; color: #94a3b8;">
|
||||||
|
若您未发起此操作或未尝试注册该账号,请直接忽略本邮件。
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- 页脚 -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 24px 32px; background-color: #f8fafc; border-top: 1px solid #e2e8f0;">
|
||||||
|
<p style="margin: 0 0 6px; font-size: 12px; line-height: 1.4; color: #94a3b8;">
|
||||||
|
此邮件为系统自动发送,请勿直接回复。
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px; color: #cbd5e1;">
|
||||||
|
© <span th:text="${#calendars.year(#calendars.createNow())}">2026</span> <span>阿萨拉向导</span>. 保留所有权利。
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user