langchat开源项目sa-token权限方面的问题的一些实战

一、权限拦截失效问题

1.1 问题现象

在项目运行过程中发现,部分需要权限验证的接口(如 /client/getChatModels)在未携带 token 的情况下仍然可以正常访问,权限拦截机制完全失效。

测试验证:

bash 复制代码
# 未携带 token 的请求
curl http://localhost:8100/client/getChatModels

# 期望:返回 401 Unauthorized
# 实际:返回 200 OK,成功获取数据

1.2 问题分析

1.2.1 排查思路
  1. 检查接口定义:确认接口路径和注解配置
  2. 检查拦截器配置:验证 Sa-Token 拦截器是否正确注册
  3. 检查路由规则 :分析 SaRouter 的匹配和排除逻辑
1.2.2 核心代码分析

定位到 AuthConfiguration.java 中的路由配置:

java 复制代码
@Bean
public SaServletFilter saServletFilter() {
    return new SaServletFilter()
            .addInclude("/**")
            .addExclude("/favicon.ico")
            .setAuth(obj -> {
                SaRouter
                        .match("/upms/**", "/aigc/**", "/app/**")
                        .check(StpUtil::checkLogin)
                        .match("/client/**")
                        .check(ClientStpUtil::checkLogin)
                        .notMatch(skipUrl)  // ❌ 问题所在
                        .notMatch(authProps.getSkipUrl().toArray(new String[0]))
                ;
            })
            .setError(this::handleError);
}

问题根源:

Sa-Token 的 SaRouter 采用链式调用设计,其执行顺序至关重要:

  1. .match(pattern) - 定义匹配规则
  2. .notMatch(pattern) - 定义排除规则
  3. .check(handler) - 执行权限校验

错误的调用顺序:

复制代码
.match() → .check() → .match() → .check() → .notMatch() → .notMatch()

在上述代码中,.notMatch() 放置在所有 .check() 之后,导致:

  • 排除规则可能作用于所有前置的 .match() 规则
  • 或者排除规则完全失效,取决于 Sa-Token 的内部实现

1.3 解决方案

1.3.1 核心思路

将路由规则分离配置 ,确保每个规则链的调用顺序为:match → notMatch → check

1.3.2 修改后的代码
java 复制代码
@Bean
public SaServletFilter saServletFilter() {
    return new SaServletFilter()
            .addInclude("/**")
            .addExclude("/favicon.ico")
            .setAuth(obj -> {
                // 管理端路由:/upms/**, /aigc/**, /app/**
                SaRouter
                        .match("/upms/**", "/aigc/**", "/app/**")
                        .check(StpUtil::checkLogin);

                // 客户端路由:/client/**(排除白名单)
                SaRouter
                        .match("/client/**")
                        .notMatch(skipUrl)  // ✅ 在 check 之前
                        .notMatch(authProps.getSkipUrl().toArray(new String[0]))
                        .check(ClientStpUtil::checkLogin);  // ✅ 最后执行校验
            })
            .setError(this::handleError);
}

改进要点:

  1. 规则分离:管理端和客户端路由独立配置
  2. 顺序优化.notMatch() 位于 .check() 之前
  3. 语义清晰:每个规则链的意图一目了然
1.3.3 白名单配置
java 复制代码
private final String[] skipUrl = new String[]{
    "/auth/login",      // 登录接口
    "/auth/logout",     // 登出接口
    "/auth/register",   // 注册接口
    "/auth/info",       // 用户信息(需根据业务调整)
    "/client/auth/**"   // 客户端认证相关接口
};

1.4 验证效果

修改后重启应用,再次测试:

bash 复制代码
# 未携带 token 的请求
curl http://localhost:8100/client/getChatModels
# 返回:401 Unauthorized ✅

# 携带 token 的请求
curl -H "Authorization: Bearer your-token" http://localhost:8100/client/getChatModels
# 返回:200 OK,成功获取数据 ✅

二、Redis Key 前缀规范化问题

2.1 问题现象

项目中 Sa-Token 的 Redis 缓存 Key 格式不符合预期规范。

期望格式:

复制代码
Authorization:login:token:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...

实际格式:

复制代码
langchat:auth:token:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...

2.2 问题分析

2.2.1 Sa-Token 的缓存机制

Sa-Token 支持自定义 SaTokenDao 接口实现,用于控制 token 的存储逻辑。项目中实现了自定义的 TokenDaoRedis

2.2.2 定位核心代码

CacheConst.java - 缓存常量定义:

java 复制代码
public interface CacheConst {
    /**
     * 系统所有Redis缓存Key前缀
     */
    String REDIS_KEY_PREFIX = "langchat:";

    /**
     * Auth缓存前缀
     */
    String AUTH_PREFIX = REDIS_KEY_PREFIX + "auth:";  // langchat:auth:

    /**
     * Auth Session缓存前缀
     */
    String AUTH_SESSION_PREFIX = AUTH_PREFIX + "session:";  // langchat:auth:session:
}

TokenDaoRedis.java - Redis DAO 实现:

java 复制代码
@Component
public class TokenDaoRedis implements SaTokenDao {

    private static final String prefix = CacheConst.AUTH_PREFIX;  // ❌ 使用业务前缀

    private static String sub(String key) {
        int index = StrUtil.ordinalIndexOf(key, ":", 2);
        return prefix + StrUtil.subSuf(key, index + 1);
    }

    @Override
    public String get(String key) {
        key = sub(key);  // Key 转换
        return stringRedisTemplate.opsForValue().get(key);
    }

    // ... 其他方法
}
2.2.3 Key 转换流程分析

Sa-Token 默认的 Key 格式为:satoken:login:token:xxx

经过 sub() 方法转换:

  1. 找到第 2 个冒号的位置(satoken:login: 之后)
  2. 截取 token:xxx
  3. 拼接前缀:langchat:auth:token:xxx

问题所在: 前缀硬编码为 CacheConst.AUTH_PREFIX,无法满足规范化需求。

2.3 解决方案

2.3.1 修改 Key 前缀
java 复制代码
// 修改前
private static final String prefix = CacheConst.AUTH_PREFIX;  // langchat:auth:

// 修改后
private static final String prefix = "Authorization:";  // ✅ 符合规范
2.3.2 优化 Key 转换逻辑

修改前的 sub() 方法:

java 复制代码
private static String sub(String key) {
    int index = StrUtil.ordinalIndexOf(key, ":", 2);  // 找第 2 个冒号
    return prefix + StrUtil.subSuf(key, index + 1);
}

存在的问题:

  • 依赖第三方工具类 StrUtil
  • 逻辑不够直观,维护成本高

优化后的 sub() 方法:

java 复制代码
private static String sub(String key) {
    // 将 Sa-Token 默认的 key 格式转换为规范格式
    // satoken:login:token:xxx -> Authorization:login:token:xxx
    int index = key.indexOf(":");
    if (index > 0) {
        return prefix + key.substring(index + 1);
    }
    return prefix + key;
}

改进要点:

  • 使用 Java 原生 API,无外部依赖
  • 逻辑清晰:找到第 1 个冒号,截取后续部分
  • 注释说明转换规则

2.4 转换效果验证

转换示例:

Sa-Token 原始 Key 转换后的 Redis Key
satoken:login:token:abc123 Authorization:login:token:abc123
satoken:login:session:user123 Authorization:login:session:user123

三、Bearer 前缀处理问题

3.1 问题现象

在某些场景下,Sa-Token 内部获取到的 tokenValue 包含了 "Bearer " 前缀,导致 token 验证失败。

问题代码路径:

复制代码
StpLogic.getLoginIdNotHandle(tokenValue)
→ tokenValue = "Bearer eyJ0eXAiOiJKV1Qi..."
→ 验证失败

3.2 问题分析

3.2.1 HTTP 请求头规范

根据 RFC 6750,Bearer Token 的标准格式为:

http 复制代码
Authorization: Bearer <token>

客户端发送请求时通常会携带 "Bearer " 前缀,但在服务端进行 token 验证时需要去除该前缀。

3.2.2 Sa-Token 的 Token 提取机制

Sa-Token 提供了 tokenPrefix 配置项,用于自动处理 token 前缀:

java 复制代码
SaTokenConfig config = new SaTokenConfig();
config.setTokenPrefix("Bearer");  // 设置前缀,框架会自动去除

项目中的配置缺失:

java 复制代码
@Bean
@Primary
public SaTokenConfig getTokenConfig() {
    return new SaTokenConfig()
            .setIsPrint(false)
            .setTokenName("Authorization")
            .setTimeout(24 * 60 * 60)
            .setTokenStyle("uuid")
            .setIsLog(false)
            .setIsReadCookie(false);
            // ❌ 缺少 tokenPrefix 配置
}

3.3 解决方案

3.3.1 添加 tokenPrefix 配置
java 复制代码
@Bean
@Primary
public SaTokenConfig getTokenConfig() {
    return new SaTokenConfig()
            .setIsPrint(false)
            .setTokenName("Authorization")
            .setTokenPrefix("Bearer")  // ✅ 新增配置
            .setTimeout(24 * 60 * 60)
            .setTokenStyle("uuid")
            .setIsLog(false)
            .setIsReadCookie(false);
}
3.3.2 工作原理

配置 tokenPrefix 后,Sa-Token 在提取 token 时会自动执行以下逻辑:

  1. 从请求头获取 Authorization 的值:Bearer eyJ0eXAiOiJKV1Qi...
  2. 检查是否以 Bearer 开头(忽略大小写)
  3. 去除前缀,得到纯净的 token:eyJ0eXAiOiJKV1Qi...
  4. 使用纯净的 token 进行后续验证

3.4 辅助工具方法

项目中已有手动处理 Bearer 前缀的工具方法(位于 AuthUtil.javaClientAuthUtil.java):

java 复制代码
/**
 * 截取前端Token字符串中不包含`Bearer`的部分
 */
public static String getToken(String token) {
    if (token != null && token.toLowerCase().startsWith("bearer")) {
        return token.replace("bearer", "").trim();
    }
    return token;
}

建议: 配置 tokenPrefix 后,该工具方法可作为备用方案,用于特殊场景的手动处理。


四、多账号体系 Key 冲突问题

4.1 问题现象

项目使用了 Sa-Token 的多账号体系功能:

  • 管理端:StpUtil(登录类型:login
  • 客户端:ClientStpUtil(登录类型:client-login

导致客户端的 Redis Key 格式为:

复制代码
Authorization:client-login:token:xxx  // ❌ 不符合预期

期望格式:

复制代码
Authorization:login:token:xxx  // ✅ 统一格式

4.2 问题分析

4.2.1 多账号体系原理

Sa-Token 支持在同一应用中定义多个账号体系,每个体系拥有独立的:

  • Token 存储空间
  • Session 会话
  • 权限体系

ClientStpUtil.java 配置:

java 复制代码
public class ClientStpUtil {
    /**
     * 多账号体系下的类型标识
     */
    public static final String TYPE = "client-login";  // ❌ 导致 key 包含 client-login

    /**
     * 底层使用的 StpLogic 对象
     */
    public static StpLogic stpLogic = new StpLogic(TYPE);
}
4.2.2 Key 生成规则

Sa-Token 生成 Key 时会使用登录类型作为中间段:

复制代码
satoken:{loginType}:token:{tokenValue}

因此:

  • 管理端:satoken:login:token:xxx
  • 客户端:satoken:client-login:token:xxx

4.3 解决方案

4.3.1 在 Key 转换时统一格式

修改 TokenDaoRedis.javasub() 方法,在转换过程中将 client-login 替换为 login

java 复制代码
private static String sub(String key) {
    // 将 Sa-Token 默认的 key 格式转换为 Authorization:login:token:xxx
    // 1. satoken:login:token:xxx -> Authorization:login:token:xxx
    // 2. satoken:client-login:token:xxx -> Authorization:login:token:xxx
    int index = key.indexOf(":");
    if (index > 0) {
        String suffix = key.substring(index + 1);
        // 将 client-login 替换为 login,统一使用 login
        suffix = suffix.replace("client-login:", "login:");  // ✅ 关键逻辑
        return prefix + suffix;
    }
    return prefix + key;
}
4.3.2 转换流程示例

管理端 Token:

复制代码
原始 Key:satoken:login:token:abc123
           ↓
截取后:  login:token:abc123
           ↓
替换后:  login:token:abc123 (无变化)
           ↓
最终 Key:Authorization:login:token:abc123 ✅

客户端 Token:

复制代码
原始 Key:satoken:client-login:token:xyz789
           ↓
截取后:  client-login:token:xyz789
           ↓
替换后:  login:token:xyz789  ← 关键转换
           ↓
最终 Key:Authorization:login:token:xyz789 ✅

4.4 注意事项

4.4.1 潜在风险

Token 冲突问题: 如果管理端和客户端生成了相同的 token 值,可能会导致覆盖。

缓解措施:

  • Sa-Token 的 UUID 模式生成的 token 碰撞概率极低
  • 可以考虑在 token 生成时添加业务标识
  • 定期监控 Redis Key 的使用情况
4.4.2 替代方案

如果业务上需要严格区分管理端和客户端的 token,可以采用以下方案:

方案 1:保留类型标识

java 复制代码
// 不进行 replace 操作,保留完整的登录类型
suffix = suffix;  // client-login 保持不变

方案 2:使用不同的前缀

java 复制代码
if (key.contains("client-login")) {
    return "Authorization:client:" + suffix;
} else {
    return "Authorization:admin:" + suffix;
}

五、完整配置总览

5.1 TokenDaoRedis.java(完整版)

java 复制代码
package cn.tycoding.langchat.auth.config;

import cn.dev33.satoken.dao.SaTokenDao;
import cn.dev33.satoken.strategy.SaStrategy;
// ... 其他导入

/**
 * Sa-Token 的 Redis 持久化实现
 *
 * @author tycoding
 * @since 2024/1/5
 */
@Slf4j
@Component
public class TokenDaoRedis implements SaTokenDao {

    // ==================== 配置常量 ====================

    /**
     * Redis Key 前缀
     * 规范:Authorization:{loginType}:{dataType}:{value}
     */
    private static final String prefix = "Authorization:";

    // ==================== Key 转换逻辑 ====================

    /**
     * 将 Sa-Token 默认的 key 格式转换为规范格式
     *
     * <p>转换规则:
     * <ul>
     *   <li>satoken:login:token:xxx → Authorization:login:token:xxx</li>
     *   <li>satoken:client-login:token:xxx → Authorization:login:token:xxx</li>
     * </ul>
     *
     * @param key Sa-Token 原始 key
     * @return 转换后的 Redis key
     */
    private static String sub(String key) {
        int index = key.indexOf(":");
        if (index > 0) {
            String suffix = key.substring(index + 1);
            // 统一登录类型标识
            suffix = suffix.replace("client-login:", "login:");
            return prefix + suffix;
        }
        return prefix + key;
    }

    // ==================== Redis 模板 ====================

    public StringRedisTemplate stringRedisTemplate;
    public RedisTemplate<String, Object> objectRedisTemplate;
    public ObjectMapper objectMapper;
    public boolean isInit;

    // ==================== 初始化 ====================

    @Autowired
    public void init(RedisConnectionFactory connectionFactory) {
        if (this.isInit) {
            return;
        }

        // 配置序列化器
        StringRedisSerializer keySerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();

        // 配置 ObjectMapper
        try {
            Field field = GenericJackson2JsonRedisSerializer.class.getDeclaredField("mapper");
            field.setAccessible(true);
            this.objectMapper = (ObjectMapper) field.get(valueSerializer);
            this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

            // 配置时间类型转换
            JavaTimeModule timeModule = new JavaTimeModule();
            timeModule.addSerializer(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            timeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            // ... 其他时间类型配置
            this.objectMapper.registerModule(timeModule);

            // 自定义 Session 生成策略
            SaStrategy.instance.createSession = (sessionId) -> new TokenDaoCustomized(sessionId);
        } catch (Exception e) {
            log.error("ObjectMapper 配置失败", e);
        }

        // 初始化 RedisTemplate
        StringRedisTemplate stringTemplate = new StringRedisTemplate();
        stringTemplate.setConnectionFactory(connectionFactory);
        stringTemplate.afterPropertiesSet();

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(keySerializer);
        template.setHashKeySerializer(keySerializer);
        template.setValueSerializer(valueSerializer);
        template.setHashValueSerializer(valueSerializer);
        template.afterPropertiesSet();

        this.stringRedisTemplate = stringTemplate;
        this.objectRedisTemplate = template;
        this.isInit = true;
    }

    // ==================== SaTokenDao 接口实现 ====================

    @Override
    public String get(String key) {
        key = sub(key);
        return stringRedisTemplate.opsForValue().get(key);
    }

    @Override
    public void set(String key, String value, long timeout) {
        key = sub(key);
        if (timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) {
            return;
        }
        if (timeout == SaTokenDao.NEVER_EXPIRE) {
            stringRedisTemplate.opsForValue().set(key, value);
        } else {
            stringRedisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
        }
    }

    @Override
    public void delete(String key) {
        key = sub(key);
        stringRedisTemplate.delete(key);
    }

    // ... 其他方法实现(update, getTimeout, updateTimeout 等)
}

5.2 TokenConfiguration.java(完整版)

java 复制代码
package cn.tycoding.langchat.auth.config;

import cn.dev33.satoken.config.SaTokenConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

/**
 * Sa-Token 配置
 *
 * @author tycoding
 * @since 2024/1/5
 */
@Configuration
public class TokenConfiguration {

    @Bean
    @Primary
    public SaTokenConfig getTokenConfig() {
        return new SaTokenConfig()
                .setIsPrint(false)                    // 关闭启动时的 banner 输出
                .setTokenName("Authorization")        // Token 名称(请求头/参数名)
                .setTokenPrefix("Bearer")             // Token 前缀(自动去除)
                .setTimeout(24 * 60 * 60)             // Token 有效期(秒)
                .setTokenStyle("uuid")                // Token 风格:uuid
                .setIsLog(false)                      // 关闭日志输出
                .setIsReadCookie(false);              // 不从 Cookie 读取 Token
    }
}

5.3 AuthConfiguration.java(完整版)

java 复制代码
package cn.tycoding.langchat.auth.config;

import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.tycoding.langchat.biz.utils.ClientStpUtil;
import cn.tycoding.langchat.common.properties.AuthProps;
import cn.tycoding.langchat.common.utils.R;
// ... 其他导入

/**
 * Sa-Token 路由拦截配置
 *
 * @author tycoding
 * @since 2024/1/5
 */
@Slf4j
@Configuration
@AllArgsConstructor
public class AuthConfiguration {

    private final SpringContextHolder contextHolder;
    private final AuthProps authProps;

    /**
     * 白名单配置
     */
    private final String[] skipUrl = new String[]{
            "/auth/login",      // 登录
            "/auth/logout",     // 登出
            "/auth/register",   // 注册
            "/auth/info",       // 用户信息
            "/client/auth/**",  // 客户端认证相关
    };

    /**
     * Sa-Token 路由拦截器
     */
    @Bean
    public SaServletFilter saServletFilter() {
        return new SaServletFilter()
                .addInclude("/**")                    // 拦截所有路由
                .addExclude("/favicon.ico")           // 排除 favicon
                .setAuth(obj -> {
                    // 管理端路由:需要管理员登录
                    SaRouter
                            .match("/upms/**", "/aigc/**", "/app/**")
                            .check(StpUtil::checkLogin);

                    // 客户端路由:需要客户端登录(排除白名单)
                    SaRouter
                            .match("/client/**")
                            .notMatch(skipUrl)
                            .notMatch(authProps.getSkipUrl().toArray(new String[0]))
                            .check(ClientStpUtil::checkLogin);
                })
                .setError(this::handleError);         // 异常处理
    }

    /**
     * 异常处理
     */
    private String handleError(Throwable e) {
        // 记录未授权访问日志
        if (e instanceof NotPermissionException || e instanceof NotRoleException) {
            String username = getUsername();
            SysLog sysLog = SysLogUtil.build(SysLogUtil.TYPE_FAIL,
                    HttpStatus.UNAUTHORIZED.getReasonPhrase(), null, null, username);
            SpringContextHolder.publishEvent(new LogEvent(sysLog));
        }

        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(
                RequestContextHolder.getRequestAttributes())).getRequest();
        log.error("Unauthorized request: {}", URLUtil.getPath(request.getRequestURI()));

        // 返回 401 响应
        SaHolder.getResponse()
                .setStatus(HttpStatus.UNAUTHORIZED.value())
                .setHeader("Content-Type", "application/json;charset=UTF-8");
        return JSON.toJSONString(R.fail(HttpStatus.UNAUTHORIZED));
    }

    /**
     * 获取当前用户名(兼容管理端和客户端)
     */
    private String getUsername() {
        try {
            return AuthUtil.getUsername();
        } catch (Exception e) {
            try {
                return ClientAuthUtil.getUsername();
            } catch (Exception ex) {
                return null;
            }
        }
    }
}

六、测试验证

6.1 权限拦截测试

测试用例 1:未携带 Token

bash 复制代码
curl -X GET http://localhost:8100/client/getChatModels

期望结果:

json 复制代码
{
  "code": 401,
  "message": "Unauthorized"
}

测试用例 2:携带有效 Token

bash 复制代码
curl -X GET http://localhost:8100/client/getChatModels \
  -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."

期望结果:

json 复制代码
{
  "code": 200,
  "data": [
    {
      "id": "1",
      "name": "GPT-4",
      "provider": "OpenAI"
    }
  ]
}

6.2 Redis Key 格式验证

连接 Redis 查看 Key:

bash 复制代码
redis-cli
127.0.0.1:6379> KEYS Authorization:*

期望输出:

复制代码
1) "Authorization:login:token:abc123def456..."
2) "Authorization:login:session:user_1234"
3) "Authorization:login:token:xyz789ghi012..."

验证要点:

  • ✅ 前缀为 Authorization:
  • ✅ 登录类型统一为 login:
  • ✅ 无 client-login 字样

6.3 Bearer 前缀处理验证

测试代码:

java 复制代码
@RestController
@RequestMapping("/test")
public class TokenTestController {

    @GetMapping("/token-info")
    public R getTokenInfo() {
        String tokenValue = StpUtil.getTokenValue();
        return R.ok(Map.of(
            "tokenValue", tokenValue,
            "hasBearer", tokenValue.toLowerCase().startsWith("bearer")
        ));
    }
}

请求:

bash 复制代码
curl -X GET http://localhost:8100/test/token-info \
  -H "Authorization: Bearer abc123"

期望响应:

json 复制代码
{
  "code": 200,
  "data": {
    "tokenValue": "abc123",
    "hasBearer": false
  }
}

验证要点:

  • tokenValue 不包含 "Bearer " 前缀
  • hasBearerfalse

七、性能优化建议

7.1 Redis 连接池优化

yaml 复制代码
spring:
  data:
    redis:
      # 连接池配置
      jedis:
        pool:
          max-active: 20        # 最大连接数
          max-idle: 10          # 最大空闲连接
          min-idle: 5           # 最小空闲连接
          max-wait: 2000        # 最大等待时间(毫秒)
      # 超时配置
      timeout: 3000             # 连接超时(毫秒)
      # 命令执行超时
      command-timeout: 3000

7.2 Token 过期策略

java 复制代码
@Bean
@Primary
public SaTokenConfig getTokenConfig() {
    return new SaTokenConfig()
            .setTimeout(24 * 60 * 60)           // Token 有效期:24小时
            .setActiveTimeout(30 * 60)          // Token 最低活跃度:30分钟
            .setIsConcurrent(true)              // 允许并发登录
            .setIsShare(false)                  // 不共享 Token
            .setMaxLoginCount(3);               // 最大登录设备数:3
}

说明:

  • activeTimeout:30 分钟无操作则冻结 Token
  • isConcurrent:允许同一账号多设备登录
  • maxLoginCount:限制最大登录设备数,超出则踢出最早的

7.3 缓存预热

在应用启动时预加载常用配置到 Redis:

java 复制代码
@Component
public class CacheWarmer implements ApplicationRunner {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void run(ApplicationArguments args) {
        // 预热系统配置
        warmupSystemConfig();

        // 预热权限数据
        warmupPermissions();
    }

    private void warmupSystemConfig() {
        // 加载系统配置到 Redis
        // ...
    }

    private void warmupPermissions() {
        // 加载权限数据到 Redis
        // ...
    }
}

八、常见问题 FAQ

Q1:修改配置后需要清理旧的 Redis Key 吗?

A: 是的,建议清理旧数据以避免混淆。

清理方案:

bash 复制代码
# 方案 1:清理特定前缀的 Key
redis-cli --scan --pattern "langchat:auth:*" | xargs redis-cli del

# 方案 2:清空整个数据库(谨慎使用)
redis-cli FLUSHDB

注意: 清理后所有用户需要重新登录。

Q2:多账号体系是否会导致 Token 冲突?

A: 理论上可能,但实际概率极低。

原因:

  • Sa-Token 的 UUID 模式使用 java.util.UUID.randomUUID()
  • UUID 的碰撞概率约为 1/2^122

缓解措施:

  • 使用 JWT 模式,在 token 中携带账号类型标识
  • 定期监控 Redis Key 的数量和分布

Q3:如何区分管理端和客户端的 Token?

A: 有三种方案:

方案 1:从 Session 中获取(推荐)

java 复制代码
// 判断当前登录的账号类型
if (StpUtil.isLogin()) {
    // 管理端用户
} else if (ClientStpUtil.isLogin()) {
    // 客户端用户
}

方案 2:在 Token 中添加标识(JWT 模式)

java 复制代码
SaTokenConfig config = new SaTokenConfig()
        .setTokenStyle("jwt")
        .setJwtSecretKey("your-secret-key");

// 登录时设置标识
StpUtil.login(userId, new SaLoginModel()
        .setExtra("userType", "admin"));

// 获取标识
String userType = StpUtil.getExtra("userType").toString();

方案 3:使用不同的 Redis 前缀(不推荐)

保留 client-login 标识,使用不同的前缀区分。

Q4:Bearer 前缀必须配置吗?

A: 不是必须的,但强烈推荐。

不配置的后果:

  • 需要手动去除 "Bearer " 前缀
  • 增加代码复杂度
  • 容易出现遗漏

最佳实践:SaTokenConfig 中统一配置 tokenPrefix

Q5:如何实现 Token 自动续期?

A: 配置 activeTimeout 即可:

java 复制代码
SaTokenConfig config = new SaTokenConfig()
        .setTimeout(24 * 60 * 60)      // 24小时过期
        .setActiveTimeout(30 * 60);    // 30分钟无操作冻结

// 每次请求自动续期
SaStrategy.instance.updateLastActiveToNow = (loginId) -> {
    // Sa-Token 会自动调用此方法更新活跃时间
};

说明: 用户每次请求都会刷新 activeTimeout,保持活跃状态。

相关推荐
DisonTangor1 分钟前
Step-Audio-R1 首个成功实现测试时计算扩展的音频语言模型
人工智能·语言模型·开源·aigc·音视频
SongYuLong的博客1 小时前
开源 C 标准库(C Library)
c语言·开发语言·开源
OpenCSG2 小时前
无需人类干预,300 轮自主思考!Kimi K2 Thinking 模型发布,多项基准达 SOTA
人工智能·开源·kimi·csghub
Tao____2 小时前
国产开源物联网基础平台
java·物联网·mqtt·开源·设备对接
致Great3 小时前
DeepSeek-V3.2技术报告解读:开源大模型的逆袭之战——如何用10%算力追平GPT-5
人工智能·gpt·开源·大模型·agent·智能体
nil3 小时前
shortcutkey:跨平台快捷键管理工具的设计与实现
python·开源·github
晚霞的不甘4 小时前
Flutter 与开源鸿蒙(OpenHarmony)性能调优与生产部署实战:从启动加速到线上监控的全链路优化
flutter·开源·harmonyos
疯不皮4 小时前
tiptiap3如何实现编辑器内部嵌套多个富文本编辑器
前端·vue.js·开源
晚霞的不甘5 小时前
Flutter 与开源鸿蒙(OpenHarmony)测试体系构建:从单元测试到真机自动化的一站式质量保障方案
flutter·开源·harmonyos