From 05ce76ad7a8f4810abfdef97546c8d3365cc4302 Mon Sep 17 00:00:00 2001
From: Azure <983547216@qq.com>
Date: Tue, 16 Jun 2026 16:02:09 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=E5=8F=8Ctoken?=
=?UTF-8?q?=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../common/config/RedisConfigurer.java | 32 +++++-
.../common/dto/account/LoginResponse.java | 4 +-
.../dto/account/RefreshTokenRequest.java | 15 +++
.../common/exception/ErrorCode.java | 2 +
.../controller/AccountController.java | 11 ++
.../service/AccountService.java | 10 +-
.../service/RefreshTokenService.java | 40 +++++++
.../service/impl/AccountServiceImpl.java | 28 ++++-
.../service/impl/RefreshTokenServiceImpl.java | 106 ++++++++++++++++++
src/main/resources/application.yml | 4 +-
10 files changed, 237 insertions(+), 15 deletions(-)
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java
create mode 100644 src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java
diff --git a/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java b/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java
index ac80bac..8788bca 100644
--- a/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java
+++ b/src/main/java/com/webgame/webgamebackend/common/config/RedisConfigurer.java
@@ -2,15 +2,39 @@ package com.webgame.webgamebackend.common.config;
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.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
-import java.time.Duration;
-
+/**
+ * Redis 配置类:序列化方式 + 消息监听容器
+ */
@Configuration
public class RedisConfigurer {
+
+ /**
+ * 配置 RedisTemplate,使用 String 序列化器替代 Java 原生序列化
+ *
+ * 不配置此项的话,Spring Boot 默认使用 JdkSerializationRedisSerializer,
+ * 存入 Redis 的数据会带有 Java 序列化魔数前缀(\xAC\xED\x00\x05),
+ * 导致人类不可读且无法被其他语言客户端读取。
+ */
+ @Bean
+ @Primary
+ public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(redisConnectionFactory);
+ StringRedisSerializer stringSerializer = new StringRedisSerializer();
+ template.setKeySerializer(stringSerializer);
+ template.setValueSerializer(stringSerializer);
+ template.setHashKeySerializer(stringSerializer);
+ template.setHashValueSerializer(stringSerializer);
+ template.afterPropertiesSet();
+ return template;
+ }
+
/**
* 创建并配置 Redis 消息监听容器
* 用于实现 Redis 的发布/订阅功能,支持监听特定频道的消息
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java
index 1955491..6fb8699 100644
--- a/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/account/LoginResponse.java
@@ -1,7 +1,7 @@
package com.webgame.webgamebackend.common.dto.account;
/**
- * 登录/注册响应
+ * 登录/注册/刷新响应,包含 accessToken 和 refreshToken
*/
-public record LoginResponse(String token) {
+public record LoginResponse(String accessToken, String refreshToken) {
}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java
new file mode 100644
index 0000000..e6373b8
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/account/RefreshTokenRequest.java
@@ -0,0 +1,15 @@
+package com.webgame.webgamebackend.common.dto.account;
+
+import com.webgame.webgamebackend.common.dto.base.BaseVo;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * 刷新令牌请求
+ */
+public record RefreshTokenRequest(
+ @NotNull(message = "刷新令牌不能为null")
+ @NotBlank(message = "刷新令牌不能为空")
+ String refreshToken
+) implements BaseVo {
+}
diff --git a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java
index fc41a76..43a895a 100644
--- a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java
+++ b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java
@@ -16,6 +16,8 @@ public enum ErrorCode {
// ========== 账号模块 ==========
USERNAME_EXISTS(1001, "用户名已存在"),
BAD_CREDENTIALS(1002, "用户名或密码错误"),
+ REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期"),
+ REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌"),
// ========== 用户模块 ==========
USER_NOT_FOUND(2001, "用户不存在"),
diff --git a/src/main/java/com/webgame/webgamebackend/controller/AccountController.java b/src/main/java/com/webgame/webgamebackend/controller/AccountController.java
index 929e364..49e526e 100644
--- a/src/main/java/com/webgame/webgamebackend/controller/AccountController.java
+++ b/src/main/java/com/webgame/webgamebackend/controller/AccountController.java
@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaIgnore;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
+import com.webgame.webgamebackend.common.dto.account.RefreshTokenRequest;
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
import com.webgame.webgamebackend.service.AccountService;
import jakarta.validation.Valid;
@@ -44,4 +45,14 @@ public class AccountController {
LoginResponse result = accountService.register(request);
return ApiResponse.ok(result);
}
+
+ /**
+ * 刷新令牌,用 refresh token 换取新的 access token 和 refresh token
+ */
+ @SaIgnore
+ @PostMapping("/refresh")
+ public ApiResponse refresh(@Valid @RequestBody RefreshTokenRequest request) {
+ LoginResponse result = accountService.refresh(request.refreshToken());
+ return ApiResponse.ok(result);
+ }
}
diff --git a/src/main/java/com/webgame/webgamebackend/service/AccountService.java b/src/main/java/com/webgame/webgamebackend/service/AccountService.java
index 12abdb7..a11ca7a 100644
--- a/src/main/java/com/webgame/webgamebackend/service/AccountService.java
+++ b/src/main/java/com/webgame/webgamebackend/service/AccountService.java
@@ -21,7 +21,15 @@ public interface AccountService {
* 注册
*
* @param request 注册请求
- * @return 注册结果(含 token,注册后自动登录)
+ * @return 注册结果(含双 token,注册后自动登录)
*/
LoginResponse register(RegisterRequest request);
+
+ /**
+ * 刷新令牌
+ *
+ * @param refreshToken 刷新令牌
+ * @return 新的双 token(accessToken + refreshToken)
+ */
+ LoginResponse refresh(String refreshToken);
}
diff --git a/src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java b/src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java
new file mode 100644
index 0000000..26c693b
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/service/RefreshTokenService.java
@@ -0,0 +1,40 @@
+package com.webgame.webgamebackend.service;
+
+/**
+ * 刷新令牌服务接口,管理 refresh token 的生命周期
+ */
+public interface RefreshTokenService {
+
+ /**
+ * 为用户创建新的刷新令牌,存入 Redis,TTL 30 天
+ *
+ * @param userId 用户ID
+ * @return 生成的 refresh token 字符串
+ */
+ String create(String userId);
+
+ /**
+ * 校验刷新令牌是否有效
+ *
+ * @param token 刷新令牌
+ * @return 对应的用户ID
+ * @throws com.webgame.webgamebackend.common.exception.BusinessException 令牌无效时抛出
+ */
+ String validate(String token);
+
+ /**
+ * 轮换刷新令牌:删除旧 token,生成新 token(防重放)
+ *
+ * @param oldToken 旧的刷新令牌
+ * @return 新的刷新令牌
+ * @throws com.webgame.webgamebackend.common.exception.BusinessException 旧令牌无效时抛出
+ */
+ String rotate(String oldToken);
+
+ /**
+ * 撤销刷新令牌(登出时调用)
+ *
+ * @param token 要撤销的刷新令牌
+ */
+ void revoke(String token);
+}
diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java
index ff868c1..86533a3 100644
--- a/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java
+++ b/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java
@@ -11,6 +11,7 @@ import com.webgame.webgamebackend.entities.UserEntity;
import com.webgame.webgamebackend.repository.AccountRepository;
import com.webgame.webgamebackend.repository.UserRepository;
import com.webgame.webgamebackend.service.AccountService;
+import com.webgame.webgamebackend.service.RefreshTokenService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -30,6 +31,7 @@ public class AccountServiceImpl implements AccountService {
private final AccountRepository accountRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
+ private final RefreshTokenService refreshTokenService;
@Override
public LoginResponse login(LoginRequest request) {
@@ -38,11 +40,13 @@ public class AccountServiceImpl implements AccountService {
if (!passwordEncoder.matches(request.password(), account.getPassword())) {
throw new BusinessException(ErrorCode.BAD_CREDENTIALS);
}
- // Sa-Token 登录
+ // Sa-Token 登录,生成 access token
StpUtil.login(account.getId());
- String token = StpUtil.getTokenValue();
+ String accessToken = StpUtil.getTokenValue();
+ // 生成 refresh token
+ String refreshToken = refreshTokenService.create(account.getId());
log.info("用户登录成功: username={}", request.username());
- return new LoginResponse(token);
+ return new LoginResponse(accessToken, refreshToken);
}
@Override
@@ -67,10 +71,22 @@ public class AccountServiceImpl implements AccountService {
userRepository.save(user);
log.info("用户注册成功: username={}", request.username());
- // 注册后自动登录
+ // 注册后自动登录,生成 access token 和 refresh token
StpUtil.login(account.getId());
- String token = StpUtil.getTokenValue();
- return new LoginResponse(token);
+ String accessToken = StpUtil.getTokenValue();
+ String refreshToken = refreshTokenService.create(account.getId());
+ return new LoginResponse(accessToken, refreshToken);
+ }
+
+ @Override
+ public LoginResponse refresh(String refreshToken) {
+ // 校验 refresh token,有效则用它签发新的 access token
+ String userId = refreshTokenService.validate(refreshToken);
+ StpUtil.login(userId);
+ String accessToken = StpUtil.getTokenValue();
+ log.info("令牌刷新成功: userId={}", userId);
+ // refresh token 保持不变,只返回新的 access token
+ return new LoginResponse(accessToken, refreshToken);
}
/**
diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java
new file mode 100644
index 0000000..7d1e641
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java
@@ -0,0 +1,106 @@
+package com.webgame.webgamebackend.service.impl;
+
+import com.webgame.webgamebackend.common.exception.BusinessException;
+import com.webgame.webgamebackend.common.exception.ErrorCode;
+import com.webgame.webgamebackend.common.utils.RedisCacheUtil;
+import com.webgame.webgamebackend.service.RefreshTokenService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.security.SecureRandom;
+import java.util.HexFormat;
+
+/**
+ * 刷新令牌服务实现,基于 Redis 存储
+ *
+ * 数据结构:
+ *
+ * - xzg:refresh:{token} → userId(正向映射,用于校验)
+ * - xzg:refresh:user:{userId} → token(反向映射,用于登录时清除旧 token)
+ *
+ * TTL: 30 天,由 Redis 自动过期
+ * 一个用户同一时间只保留一个有效的 refresh token,新登录会使旧 token 失效
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RefreshTokenServiceImpl implements RefreshTokenService {
+
+ /** Redis key 前缀 */
+ private static final String PREFIX = "refresh";
+
+ /** 用户→token 反向映射 key 前缀 */
+ private static final String USER_PREFIX = "refresh:user";
+
+ /** 刷新令牌有效期:30 天(毫秒) */
+ private static final long EXPIRE_MS = 2_592_000_000L;
+
+ /** 安全随机数生成器 */
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private final RedisCacheUtil redisCacheUtil;
+
+ @Override
+ public String create(String userId) {
+ // 先清除该用户之前的 refresh token,确保一个用户只有一个有效 token
+ String oldToken = redisCacheUtil.getCache(USER_PREFIX, userId, String.class, EXPIRE_MS);
+ if (oldToken != null) {
+ redisCacheUtil.removeCache(PREFIX, oldToken);
+ log.debug("已清除旧的刷新令牌: userId={}", userId);
+ }
+ // 创建新 token
+ String token = generateToken();
+ boolean ok = redisCacheUtil.cacheValue(PREFIX, token, userId, EXPIRE_MS);
+ if (!ok) {
+ log.error("刷新令牌写入 Redis 失败: userId={}", userId);
+ throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌生成失败");
+ }
+ // 维护反向映射
+ redisCacheUtil.cacheValue(USER_PREFIX, userId, token, EXPIRE_MS);
+ log.debug("刷新令牌已创建: userId={}", userId);
+ return token;
+ }
+
+ @Override
+ public String validate(String token) {
+ if (token == null || token.isBlank()) {
+ throw new BusinessException(ErrorCode.REFRESH_TOKEN_MISSING);
+ }
+ String userId = redisCacheUtil.getCache(PREFIX, token, String.class, EXPIRE_MS);
+ if (userId == null) {
+ throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
+ }
+ return userId;
+ }
+
+ @Override
+ public String rotate(String oldToken) {
+ String userId = validate(oldToken);
+ redisCacheUtil.removeCache(PREFIX, oldToken);
+ redisCacheUtil.removeCache(USER_PREFIX, userId);
+ log.debug("旧刷新令牌已删除: userId={}", userId);
+ return create(userId);
+ }
+
+ @Override
+ public void revoke(String token) {
+ if (token != null && !token.isBlank()) {
+ String userId = redisCacheUtil.getCache(PREFIX, token, String.class, EXPIRE_MS);
+ if (userId != null) {
+ redisCacheUtil.removeCache(USER_PREFIX, userId);
+ }
+ redisCacheUtil.removeCache(PREFIX, token);
+ log.debug("刷新令牌已撤销");
+ }
+ }
+
+ /**
+ * 生成 64 位十六进制随机字符串作为刷新令牌
+ */
+ private String generateToken() {
+ byte[] bytes = new byte[32];
+ RANDOM.nextBytes(bytes);
+ return HexFormat.of().formatHex(bytes);
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 6a3c785..a29dc92 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -71,8 +71,8 @@ logging:
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: saToken
- # token 有效期(单位:秒) 默认30天,-1 代表永久有效
- timeout: 2592000
+ # token 有效期(单位:秒)access token 30分钟,refresh token 由应用层管理
+ timeout: 1800
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)