Sa-Token 多端登录隔离实战:小程序与Web后台互不踢线

设计背景:同一个用户,小程序登录后再登录Web后台,小程序的token会被踢掉------这大概是多端系统最常见的认证翻车之一。本文用一个真实项目,完整记录如何用 Sa-Token 的多 StpLogic 方案彻底解决这个问题。


一、问题现象

同一个 userId,分别在微信小程序和Web后台登录时:

  • 小程序登录成功 → 刷新Web后台 → 提示"被踢下线"
  • Web后台登录成功 → 小程序重新登录 → Web后台token失效

根本原因 :两端共用了同一个 StpUtil(同一个 loginType),Sa-Token 配置了 is-concurrent: false,同一账号只能有一个活跃 token,新登录必然踢掉旧登录。


二、解决思路

核心原理 :为每个端创建独立的 StpLogic 实例,各端维护各自的 token 空间,互不干扰。

复制代码
改造前:
  小程序 ──login──▶ StpUtil (loginType="login") ──踢掉──▶ Web后台

改造后:
  小程序 ──login──▶ StpAppUtil (loginType="app")  ←─互不影响─▶ StpWebUtil (loginType="web") ◀──login── Web后台

端的识别方式 :前端在每个请求头带上 X-Device-Type,后端拦截器根据该值选择对应的 StpLogic 进行鉴权。


三、整体流程图

复制代码
┌──────────────────────────────────────────────────────────────┐
│                        前端发送请求                            │
│  Header: { Authentication: "xxx", X-Device-Type: "web" }     │
└────────────────────────────┬─────────────────────────────────┘
                             ▼
┌──────────────────────────────────────────────────────────────┐
│                    VopInterceptor 拦截器                       │
│  读取 X-Device-Type 请求头                                     │
│    ├─ "web" ──▶ StpWebUtil.checkLogin()                      │
│    └─ 其他  ──▶ StpAppUtil.checkLogin()                      │
│  将设备类型存入 SaStorage(供后续 Service 层使用)               │
└────────────────────────────┬─────────────────────────────────┘
                             ▼
┌──────────────────────────────────────────────────────────────┐
│                  @SaCheckRole 注解(如需要)                   │
│  @SaCheckRole(value="SUPER_ADMIN", type="web")               │
│  角色校验只走 StpWebUtil,小程序端直接跳过                      │
└────────────────────────────┬─────────────────────────────────┘
                             ▼
┌──────────────────────────────────────────────────────────────┐
│                     Controller → Service                      │
│  从 SaStorage 取出设备类型,调用对应的 StpUtil 获取 userId       │
└──────────────────────────────────────────────────────────────┘

四、后端实现

4.1 为每个端创建 StpLogic 工具类

StpAppUtil.java --- 小程序端(loginType = "app"

java 复制代码
package com.xiaoqian.security;

import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.stp.StpLogic;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import lombok.Getter;

public class StpAppUtil {

    public static final String TYPE = "app";

    @Getter
    public static StpLogic stpLogic;

    private StpAppUtil() {}

    public static void setStpLogic(StpLogic logic) {
        stpLogic = logic;
        SaManager.stpLogicMap.put(TYPE, logic);  // 注册到全局,让 @SaCheckRole 能找到
    }

    public static void login(Object id) {
        stpLogic.login(id);
    }

    public static void checkLogin() {
        stpLogic.checkLogin();
    }

    public static long getLoginIdAsLong() {
        return stpLogic.getLoginIdAsLong();
    }

    public static String getTokenValue() {
        return stpLogic.getTokenValue();
    }

    public static void logout() {
        stpLogic.logout();
    }
}

StpWebUtil.java --- Web后台端(loginType = "web"

java 复制代码
package com.xiaoqian.security;

import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.stp.StpLogic;
import lombok.Getter;

public class StpWebUtil {

    public static final String TYPE = "web";

    @Getter
    public static StpLogic stpLogic;

    private StpWebUtil() {}

    public static void setStpLogic(StpLogic logic) {
        stpLogic = logic;
        SaManager.stpLogicMap.put(TYPE, logic);
    }

    public static void login(Object id) {
        stpLogic.login(id);
    }

    public static void checkLogin() {
        stpLogic.checkLogin();
    }

    public static long getLoginIdAsLong() {
        return stpLogic.getLoginIdAsLong();
    }

    public static String getTokenValue() {
        return stpLogic.getTokenValue();
    }

    public static void logout() {
        stpLogic.logout();
    }
}

关键点SaManager.stpLogicMap.put(TYPE, logic) 将实例注册到 Sa-Token 全局,这样 @SaCheckRole(type="web") 注解才能找到对应的 StpLogic


4.2 多端配置类:初始化两个 StpLogic

java 复制代码
package com.xiaoqian.config;

import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.config.SaTokenConfig;
import cn.dev33.satoken.stp.StpLogic;
import com.xiaoqian.security.StpAppUtil;
import com.xiaoqian.security.StpWebUtil;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SaMultiTerminalConfig {

    private static final long APP_TIMEOUT = 2592000L;  // 30天(秒)
    private static final long WEB_TIMEOUT = 18000L;    // 5小时(秒)

    @Value("${sa-token.token-name}")
    private String tokenName;
    @Value("${sa-token.token-style}")
    private String tokenStyle;
    @Value("${sa-token.is-concurrent}")
    private boolean isConcurrent;
    @Value("${sa-token.is-share}")
    private boolean isShare;
    @Value("${sa-token.is-print}")
    private boolean isPrint;

    @PostConstruct
    public void init() {
        // 小程序端:30天有效期
        SaTokenConfig appConfig = buildConfig(APP_TIMEOUT);
        StpLogic appLogic = new StpLogic(StpAppUtil.TYPE);
        appLogic.setConfig(appConfig);
        StpAppUtil.setStpLogic(appLogic);

        // Web后台端:5小时有效期
        SaTokenConfig webConfig = buildConfig(WEB_TIMEOUT);
        StpLogic webLogic = new StpLogic(StpWebUtil.TYPE);
        webLogic.setConfig(webConfig);
        StpWebUtil.setStpLogic(webLogic);
    }

    private SaTokenConfig buildConfig(long timeout) {
        SaTokenConfig config = new SaTokenConfig();
        config.setTokenName(tokenName);
        config.setTimeout(timeout);
        config.setActiveTimeout(-1L);
        config.setIsConcurrent(isConcurrent);  // false = 同端互踢
        config.setIsShare(isShare);
        config.setIsPrint(isPrint);
        config.setTokenStyle(tokenStyle);
        // 继承全局的读写配置
        config.setIsReadBody(SaManager.getConfig().getIsReadBody());
        config.setIsReadHeader(SaManager.getConfig().getIsReadHeader());
        config.setIsReadCookie(SaManager.getConfig().getIsReadCookie());
        config.setIsWriteHeader(SaManager.getConfig().getIsWriteHeader());
        config.setCookie(SaManager.getConfig().cookie);
        return config;
    }
}

核心is-concurrent: false 保证同端内互踢;不同 loginTypeStpLogic 完全独立,跨端不会互踢。


4.3 拦截器:根据请求头选择鉴权逻辑

java 复制代码
package com.xiaoqian.security;

import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Set;

@Configuration
public class VopInterceptor implements WebMvcConfigurer {

    public static final String DEVICE_TYPE_HEADER = "X-Device-Type";
    public static final String DEVICE_TYPE_ATTR = "deviceType";

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        Set<String> excludePaths = getExcludePaths();

        registry.addInterceptor(new SaInterceptor(handle -> SaRouter.match("/**")
                .notMatch(excludePaths.toArray(new String[0]))
                .check(r -> {
                    // 1. 读取请求头,判断来自哪一端
                    String deviceType = SaHolder.getRequest().getHeader(DEVICE_TYPE_HEADER);

                    // 2. 调用对应端的 checkLogin
                    if (StpWebUtil.TYPE.equals(deviceType)) {
                        StpWebUtil.checkLogin();
                    } else {
                        StpAppUtil.checkLogin();
                    }

                    // 3. 存入 SaStorage,供 Service 层获取当前登录用户
                    SaHolder.getStorage().set(DEVICE_TYPE_ATTR, deviceType);
                })
        )).addPathPatterns("/**");
    }
}

为什么用 SaHolder 而不是 HttpServletRequest

SaHolder.getStorage() 是 Sa-Token 提供的 thread-local 存储,与请求生命周期完全匹配,比 Spring 的 RequestContextHolder 更轻量,且在 Sa-Token 上下文中使用更安全。


4.4 登录逻辑:各端调用自己的 StpUtil

java 复制代码
// 小程序登录
public RR<UserResponse> wxLogin(UserWxLoginRequest request) {
    // ... 微信 code2Session 获取 openid ...
    User user = /* 查找或注册用户 */;
    StpAppUtil.login(user.getId());              // ← 用 StpAppUtil
    return RR.successResult(fillUserResponse(user, StpAppUtil.TYPE));
}

// Web后台登录
public RR<UserResponse> webLogin(WebLoginRequest request) {
    // ... 邮箱验证码校验 ...
    User user = /* 查找用户,校验管理员身份 */;
    StpWebUtil.login(user.getId());              // ← 用 StpWebUtil
    return RR.successResult(fillUserResponse(user, StpWebUtil.TYPE));
}

// 通用接口(两端都可能调用)
public RR<UserResponse> updateUserInfo(UserInfoUpdateRequest request) {
    String deviceType = getCurrentDeviceType();   // 从 SaStorage 取出
    long userId = getCurrentLoginId();            // 根据设备类型取对应 StpUtil 的 userId
    request.setId(userId);
    // ... 更新用户信息 ...
    return RR.successResult(fillUserResponse(user, deviceType));
}

// 辅助方法
private String getCurrentDeviceType() {
    Object attr = SaHolder.getStorage().get(VopInterceptor.DEVICE_TYPE_ATTR);
    return StpWebUtil.TYPE.equals(attr) ? StpWebUtil.TYPE : StpAppUtil.TYPE;
}

private long getCurrentLoginId() {
    return StpWebUtil.TYPE.equals(getCurrentDeviceType())
            ? StpWebUtil.getLoginIdAsLong()
            : StpAppUtil.getLoginIdAsLong();
}

4.5 权限校验:只有 Web 端才走角色校验

java 复制代码
@Component
public class VopAuthorizationInterceptor implements StpInterface {

    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 小程序端不校验角色
        if (!StpWebUtil.TYPE.equals(loginType)) {
            return new ArrayList<>();
        }
        // Web端:查数据库角色列表
        Long userId = Long.valueOf(loginId.toString());
        // ... 查角色逻辑 ...
        return roleCodes;
    }
}

4.6 @SaCheckRole 注解:指定 type

所有需要管理员权限的 Controller,注解改为指定 type = "web"

java 复制代码
// 改造前
@SaCheckRole(SysRoleConst.SUPER_ADMIN)

// 改造后
@SaCheckRole(value = SysRoleConst.SUPER_ADMIN, type = "web")

涉及文件:

  • SysDictController.java(类级别)
  • SysRoleController.java(类级别)
  • SysUserRoleController.java(类级别)
  • UserContactController.java(类级别)
  • UserController.java(方法级别,2处)

4.7 MybatisPlus 自动填充:兼容两个 StpUtil

java 复制代码
private long resolveUserId() {
    try {
        return StpAppUtil.getLoginIdAsLong();
    } catch (Exception ignored) {}
    try {
        return StpWebUtil.getLoginIdAsLong();
    } catch (Exception ignored) {}
    return -1L;  // 未登录场景(如定时任务)
}

4.8 YAML 配置(application.yaml)

yaml 复制代码
sa-token:
  token-name: Authentication
  timeout: 2592000       # 30天(默认,供 SaMultiTerminalConfig 继承)
  active-timeout: -1
  is-concurrent: false   # 同端内新登录踢掉旧登录
  is-share: false        # 每次登录生成新 token
  token-style: tik
  is-print: false

五、前端实现

5.1 Web后台(Axios 拦截器)

vop-front-web/src/utils/request.js

js 复制代码
instance.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authentication = token
  }
  // 关键:告诉后端这是 Web 后台端的请求
  config.headers['X-Device-Type'] = 'web'
  return config
})

5.2 小程序端(uni.request 封装)

vop-front-uniapp/src/utils/request.js

js 复制代码
uni.request({
    url: url,
    method: options.method || 'GET',
    data: options.data || {},
    header: {
        'Authentication': token || '',
        'Content-Type': 'application/json',
        'X-Device-Type': 'app',    // 关键:告诉后端这是小程序端的请求
        ...options.header
    },
    // ...
})

六、效果对照

场景 改造前 改造后
小程序登录 → Web登录 Web登录踢掉小程序 token 两端 token 独立,互不影响
Web登录 → 小程序登录 小程序登录踢掉 Web token 两端 token 独立,互不影响
小程序重复登录 新登录踢掉旧登录 同端内互踢,正常
Web端重复登录 新登录踢掉旧登录 同端内互踢,正常
小程序访问后台管理接口 通过(无角色校验) 拦截,app端无角色数据

七、关键踩坑记录

坑1:SaRouterStaff 没有 getRequest() 方法

拦截器 lambda 里的 r 类型是 SaRouterStaff,不能直接调 r.getRequest()

解决 :用 SaHolder.getRequest() 获取 Sa-Token 的请求对象。

坑2:getCurrentDeviceType() 如何跨层传递

拦截器知道设备类型,但 Service 层不知道。

解决 :拦截器用 SaHolder.getStorage().set() 存入 thread-local,Service 层用 SaHolder.getStorage().get() 取出。不要用 HttpServletRequest.setAttribute(),因为在非 Servlet 环境或异步场景下不可靠。

坑3:@SaCheckRole 默认用的是 StpUtil 而不是自定义的 StpLogic

@SaCheckRole 注解不指定 type 时,默认走 StpUtil(loginType="login"),而我们的 StpWebUtil 的 loginType 是 "web",所以角色校验永远失败。

解决 :所有 admin 接口的注解改为 @SaCheckRole(value = ..., type = "web")


八、总结

复制代码
改造成本:
  新增 2 个文件(StpAppUtil / StpWebUtil)
  新增 1 个配置类(SaMultiTerminalConfig)
  修改 1 个拦截器(VopInterceptor)
  修改 1 个登录服务(UserServiceImpl)
  修改 5 个 Controller 的注解
  修改 2 个前端请求工具(各加一行 header)

核心原理:
  每个端一个独立的 StpLogic 实例 → 各自独立的 token 空间
  请求头 X-Device-Type 告知后端来自哪一端
  拦截器根据端选择对应 StpLogic 进行鉴权

本质上就是利用了 Sa-Token 的多 loginType 能力,将原本共享的认证空间按端拆分成独立的认证空间。同端内互踢不变,跨端完全隔离。