设计背景:同一个用户,小程序登录后再登录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保证同端内互踢;不同loginType的StpLogic完全独立,跨端不会互踢。
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 能力,将原本共享的认证空间按端拆分成独立的认证空间。同端内互踢不变,跨端完全隔离。