INACTIVEACTIVE
@Entity
@Table(name = "sys_user")
@Data
@Builder
@NoArgsConstructor @AllArgsConstructor
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 100)
private String email;
@Column(nullable = false, length = 100)
private String password; // BCrypt 加密存儲
@Column(nullable = false, length = 50)
private String username;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private UserStatus status = UserStatus.INACTIVE; // INACTIVE, ACTIVE, LOCKED
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "activated_at")
private LocalDateTime activatedAt;
public enum UserStatus { INACTIVE, ACTIVE, LOCKED }
}
@Component
@ConfigurationProperties(prefix = "app.jwt")
@Data
public class JwtTokenProvider {
private String secret; // Base64 編碼的密鑰,至少 256 位
private long expirationMs; // 過期時間,如 86400000 (24h)
private String issuer; // 簽發者
private Key key;
@PostConstruct
public void init() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 生成激活 Token
public String generateActivationToken(User user) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.setSubject(user.getEmail())
.claim("userId", user.getId())
.claim("type", "ACTIVATION")
.setIssuer(issuer)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// 解析並驗證 Token
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.requireIssuer(issuer)
.build()
.parseClaimsJws(token)
.getBody();
}
// 校驗 Token 有效性
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
// 從 Token 獲取用戶 ID
public Long getUserIdFromToken(String token) {
return parseToken(token).get("userId", Long.class);
}
}
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final RedisTemplate redisTemplate;
private static final String BLACKLIST_PREFIX = "token:blacklist:";
// 加入黑名單 (激活成功後調用)
public void blacklist(String token, long ttlMs) {
String key = BLACKLIST_PREFIX + token;
redisTemplate.opsForValue().set(key, "1", ttlMs, TimeUnit.MILLISECONDS);
}
// 檢查是否在黑名單
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + token));
}
}
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
private final JwtTokenProvider jwtProvider;
@Value("${app.base-url}")
private String baseUrl; // 如 https://api.example.com
@Async("emailExecutor") // 使用專用線程池
public void sendActivationEmail(User user) {
String token = jwtProvider.generateActivationToken(user);
String activationUrl = baseUrl + "/api/auth/activate?token=" + token;
Context context = new Context();
context.setVariable("username", user.getUsername());
context.setVariable("activationUrl", activationUrl);
context.setVariable("expireHours", jwtProvider.getExpirationMs() / 3600000);
String htmlContent = templateEngine.process("email/activation", context);
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setTo(user.getEmail());
helper.setSubject("歡迎註冊 - 請激活您的賬號");
helper.setText(htmlContent, true); // true = HTML
mailSender.send(message);
log.info("激活郵件已發送至: {}", user.getEmail());
} catch (MessagingException e) {
log.error("郵件發送失敗: {}", user.getEmail(), e);
// 可加入重試隊列或告警
}
}
}
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Validated
public class AuthController {
private final UserService userService;
private final EmailService emailService;
private final JwtTokenProvider jwtProvider;
private final TokenBlacklistService blacklistService;
@PostMapping("/register")
public Result register(@Valid @RequestBody RegisterDTO dto) {
// 1. 校驗郵箱唯一性
if (userService.existsByEmail(dto.getEmail())) {
return Result.error("郵箱已被註冊");
}
// 2. 創建用戶 (密碼加密)
User user = User.builder()
.email(dto.getEmail())
.username(dto.getUsername())
.password(passwordEncoder.encode(dto.getPassword()))
.status(User.UserStatus.INACTIVE)
.createdAt(LocalDateTime.now())
.build();
userService.save(user);
// 3. 異步發送激活郵件
emailService.sendActivationEmail(user);
return Result.success("註冊成功,請查收郵件激活賬號");
}
@GetMapping("/activate")
public Result activate(@RequestParam @NotBlank String token) {
// 1. 基礎驗證
if (!jwtProvider.validateToken(token)) {
return Result.error("激活鏈接無效或已過期");
}
if (blacklistService.isBlacklisted(token)) {
return Result.error("激活鏈接已使用");
}
// 2. 解析用戶 ID
Long userId = jwtProvider.getUserIdFromToken(token);
User user = userService.getById(userId);
if (user == null) {
return Result.error("用戶不存在");
}
if (user.getStatus() == User.UserStatus.ACTIVE) {
return Result.error("賬號已激活,請直接登錄");
}
// 3. 激活用戶
user.setStatus(User.UserStatus.ACTIVE);
user.setActivatedAt(LocalDateTime.now());
userService.updateById(user);
// 4. Token 加入黑名單 (防止重複使用)
blacklistService.blacklist(token, jwtProvider.getExpirationMs());
return Result.success("激活成功,請登錄");
}
}
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("emailExecutor")
public Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
app:
jwt:
secret: "your-base64-encoded-256-bit-secret-key-here" # 生產環境用環境變量注入
expiration-ms: 86400000 # 24小時
issuer: "my-app"
base-url: "https://api.yourdomain.com"
spring:
mail:
host: smtp.exmail.qq.com
port: 465
username: noreply@yourdomain.com
password: ${MAIL_PASSWORD} # 環境變量
protocol: smtps
properties:
mail:
smtp:
ssl:
enable: true
auth: true
timeout: 10000
redis:
host: localhost
port: 6379
thymeleaf:
prefix: classpath:/templates/
suffix: .html
encoding: UTF-8
mode: HTML
register:ip:{ip} 計數,超過閾值封禁 1hencodeURIComponent(token)/activate 路徑同理,將 JWT 中 type 改為 PHONE_VERIFY,發送短信而非郵件,激活接口驗證短信驗證碼 (存儲於 Redis,5 分鐘過期)。
本方案採用 JWT 無狀態 Token + Redis 黑名單 + 異步郵件 架構,具備高性能、水平擴展、安全可靠特點。核心優勢:無需數據庫存儲激活碼、Token 自帶過期時間、異步不阻塞註冊響應、模板引擎靈活定制郵件內容。生產部署時注意密鑰管理、HTTPS 強制、限流防刷,即可構建企業級用戶註冊激活體系。