package com.webgame.webgamebackend.service.impl; import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.ErrorCode; import com.webgame.webgamebackend.service.RefreshTokenService; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.security.SecureRandom; import java.util.HexFormat; import java.util.concurrent.TimeUnit; /** * 刷新令牌服务实现,基于 Redis 存储 * *
数据结构:
*TTL: 30 天,由 Redis 自动过期
*一个用户同一时间只保留一个有效的 refresh token,新登录会使旧 token 失效
*直接使用 StringRedisTemplate 存储纯字符串,避免 JSON 序列化导致的引号问题
*/ @Slf4j @Service public class RefreshTokenServiceImpl implements RefreshTokenService { /** Redis key 前缀 */ private static final String KEY_PREFIX = "xzg:refresh:"; /** 用户→token 反向映射 key 前缀 */ private static final String USER_KEY_PREFIX = "xzg:refresh:user:"; /** 刷新令牌有效期:30 天 */ private static final long EXPIRE_DAYS = 30; /** 安全随机数生成器 */ private static final SecureRandom RANDOM = new SecureRandom(); private final StringRedisTemplate stringRedisTemplate; public RefreshTokenServiceImpl(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Override public String create(String userId) { if (userId == null || userId.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "用户ID不能为空"); } // 先清除该用户之前的 refresh token,确保一个用户只有一个有效 token String oldToken = get(USER_KEY_PREFIX + userId); if (oldToken != null) { delete(KEY_PREFIX + oldToken); log.debug("已清除旧的刷新令牌: userId={}", userId); } // 创建新 token String token = generateToken(); log.debug("刷新令牌已生成: token={}", token); set(KEY_PREFIX + token, userId); // 维护反向映射 set(USER_KEY_PREFIX + userId, token); 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 = get(KEY_PREFIX + token); if (userId == null) { throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID); } return userId; } @Override public String rotate(String oldToken) { String userId = validate(oldToken); // 先删反向映射,再删正向映射: // 若反向映射删除失败,正向映射仍存在,调用方可重试; // 若反向已删除而正向删除失败,正向映射仍存在,重试仍可通过。 delete(USER_KEY_PREFIX + userId); delete(KEY_PREFIX + oldToken); log.debug("旧刷新令牌已删除: userId={}", userId); return create(userId); } @Override public void revoke(String token) { if (token == null || token.isBlank()) { return; } try { String userId = get(KEY_PREFIX + token); if (userId != null) { delete(USER_KEY_PREFIX + userId); } delete(KEY_PREFIX + token); log.debug("刷新令牌已撤销"); } catch (BusinessException e) { // 登出时 Redis 不可用,记录日志后放行,不影响用户登出 log.warn("撤销刷新令牌失败(Redis 不可用),跳过清理: token={}", token, e); } } /** * 写入字符串到 Redis(带过期时间) */ private void set(String key, String value) { try { stringRedisTemplate.opsForValue().set(key, value, EXPIRE_DAYS, TimeUnit.DAYS); } catch (Exception e) { log.error("刷新令牌写入 Redis 失败: key={}", key, e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌操作失败"); } } /** * 从 Redis 读取字符串(读取时续期) * * @return 值,key 不存在时返回 null * @throws BusinessException Redis 操作失败时抛出 */ private String get(String key) { try { String value = stringRedisTemplate.opsForValue().get(key); if (value != null) { stringRedisTemplate.expire(key, EXPIRE_DAYS, TimeUnit.DAYS); } return value; } catch (Exception e) { log.error("查询缓存失败: key={}", key, e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌操作失败"); } } /** * 从 Redis 删除 key * * @throws BusinessException Redis 操作失败时抛出 */ private void delete(String key) { try { stringRedisTemplate.delete(key); } catch (Exception e) { log.error("删除缓存失败: key={}", key, e); throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌操作失败"); } } /** * 生成 64 位十六进制随机字符串作为刷新令牌 */ private String generateToken() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return HexFormat.of().formatHex(bytes); } }