登录注册番外篇(二) - Sa-Token:2026年最新登录模式全解与会话管理


第一章. 三种登录模式:一个配置搞定多端策略

阶段式学习路径

在番外篇(一)中,我们用 StpUtil.login(10001) 完成了最基础的登录,但没有回答一个关键问题:同一个账号能不能在多个设备上同时登录?如果能,上限是多少?如果不能,新登录是挤掉旧登录还是拒绝?

回忆一下基础篇——为了实现多设备管理,我们手写了 ZSet 存储、顶号逻辑、设备信息提取,三个类协作将近 100 行代码。而 Sa-Token 把这些策略抽象成了两个配置项和一个登录参数。

本章我们将体验 Sa-Token 的三种登录模式,并理解它们背后的配置组合。

1.1. 两个配置项决定登录行为

打开 application.yaml,关注这两个配置:

1
2
3
sa-token:
is-concurrent: true
is-share: false

is-concurrent 控制"能不能同时登录",is-share 控制"同时登录时是否共用 Token"。两者是独立的维度,组合起来产生三种登录模式:

is-concurrentis-share登录模式行为描述
truetrue多端共享 Token多次登录拿到同一个 Token,最宽松
truefalse多端独立 Token每次登录生成新 Token,旧 Token 不失效
false单端登录新登录自动挤掉旧登录,同一时间只有一个有效会话

我们项目当前的配置是 is-concurrent: true + is-share: false,也就是第二种模式——允许多端同时登录,每次登录生成独立的 Token。这是最常用的配置,因为它既允许多设备在线,又能区分不同设备的会话。

is-concurrent=false 时,is-share 的值不影响行为,因为同一时间只会存在一个有效会话。


1.2. 同端互斥登录:QQ 模式

如果你用过腾讯 QQ,你会发现它的策略很有意思:手机和电脑可以同时在线,但不能在两台手机上同时登录。这就是"同端互斥登录"——同类型设备互斥,不同类型设备共存。

在基础篇中我们完全没有实现这个功能,因为它需要在 ZSet 中按设备类型分组管理,复杂度远超当时的架构设计。

而在 Sa-Token 中,实现同端互斥只需要在登录时指定设备类型。回顾一下番外篇(一)中 LoginController 的登录代码——当时我们用的是 StpUtil.login(10001),没有传设备类型。现在我们需要升级它。

📄 文件:src/main/java/com/example/authsatoken/controller/LoginController.java(修改)

将原来的 login 方法替换为以下版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@PostMapping("/login")
public SaResult login(@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false, defaultValue = "PC") String device) {

// 1. 校验账户(模拟逻辑)
Map<String, String> userPasswordMap = Map.of(
"admin", "123456",
"user", "123456"
);

if (!userPasswordMap.containsKey(username) ||
!userPasswordMap.get(username).equals(password)) {
return SaResult.error("用户名或密码错误");
}

// 2. 准备登录参数
long userId = 10001L;
boolean isAdmin = "admin".equals(username);

// 3. 差异化配置
SaLoginParameter loginParam = new SaLoginParameter()
.setDeviceType(device) // 设置此次登录的设备类型
.setIsConcurrent(!isAdmin) // 管理员: 不允许并发; 普通用户: 允许并发
.setMaxLoginCount(isAdmin ? 1 : 2); // 管理员: 最多1个; 普通用户: 最多2个

// 4. 执行登录
StpUtil.login(userId, loginParam);

return SaResult.ok("登录成功").setData(StpUtil.getTokenValue());
}

这段代码相比番外篇(一)的版本,有三个关键升级:

第一,设备类型参数。 device 参数由前端传入,默认值为 "PC"SaLoginParameter.setDeviceType(device) 告诉 Sa-Token:这次登录来自什么设备。当同一账号以相同设备类型再次登录时,旧会话会被自动顶替;不同设备类型的会话互不干扰。

第二,角色差异化策略。 管理员账号 setIsConcurrent(false)——不允许并发登录,新登录直接挤掉旧登录。普通用户 setIsConcurrent(true)——允许多端同时在线。这个逻辑在基础篇中需要在 AuthService 里写一大段 if-else 分支,现在只需要一个三元表达式。

第三,设备数量限制。 setMaxLoginCount(isAdmin ? 1 : 2) 限制了同一账号的最大在线设备数。管理员最多 1 个(配合 isConcurrent=false 实现严格单端),普通用户最多 2 个(超过时自动踢掉最早的会话)。回忆一下基础篇——为了实现这个"超限自动踢掉"的逻辑,我们手写了 ZSet 大小判断 → 找到最早 Token → 加入黑名单的整套流程。

SaLoginParameter 的配置优先级高于 application.yaml 的全局配置,适合按角色动态调整登录策略。

重启项目后验证同端互斥的效果:

步骤 1:以 PC 端登录

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=PC

步骤 2:以 APP 端登录

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=APP

两个会话都有效——因为它们是不同设备类型。

步骤 3:再次以 PC 端登录

1
POST http://localhost:8081/auth/login?username=user&password=123456&device=PC

步骤 1 的 PC 端会话被顶替,步骤 2 的 APP 端不受影响。


1.3. 本章小结

本章理解了 is-concurrentis-share 两个配置项的组合逻辑,体验了同端互斥登录的实现方式,并将登录方法升级为支持设备类型、角色差异化策略、设备数量限制的完整版本。

要点何时使用关键动作
is-concurrent + is-share 组合项目初始化阶段确定登录策略根据业务需求选择配置组合
SaLoginParameter 动态配置不同角色需要不同登录策略时在登录时覆盖全局配置
setMaxLoginCount()需要限制同时在线设备数时超限自动踢掉最早登录的会话

第二章. 三种下线方式与全局异常处理

在第一章中,我们提到"新登录挤掉旧登录"——被挤掉的用户再次访问时,Sa-Token 会抛出 NotLoginException。但"被挤掉"只是下线的一种方式。Sa-Token 一共有三种下线途径,它们的触发场景和用户感知完全不同。

在基础篇中,我们只有两种下线方式:用户主动注销和管理员踢人(将 Token 加入黑名单)。而且这两种方式在用户端的表现完全一样——都是"Token 无效",无法区分原因。

2.1. logout、kickout、replaced 的本质区别

先用一张表说清楚:

下线方式触发者场景值典型提示基础篇对应
logout用户自己-2 / -3“请重新登录”AuthService.logout() + 黑名单
kickout管理员 / 系统-5“您已被管理员强制下线”黑名单(无法区分场景)
replaced新登录自动触发-4“您的账号已在其他设备登录”ZSet 顶号(无法区分场景)

logout 是正常退出——番外篇(一)中我们已经在 LoginController 里写过 StpUtil.logout()。除了注销当前会话,它还支持更灵活的用法:

1
2
3
StpUtil.logout(10001);           // 注销指定账号的所有会话(全端下线)
StpUtil.logout(10001, "PC"); // 只注销指定账号的 PC 端
StpUtil.logoutByTokenValue(token); // 根据 Token 值注销指定会话

kickout 是管理员视角的强制下线。replaced 是单端登录模式下新登录自动顶替旧登录。三者的 API 形式相似,但场景值不同——这让前端可以根据不同原因展示差异化的提示信息。


2.2. 创建管理控制器

踢人下线是管理员操作,我们把它放在独立的 AdminController 中,与用户侧的 LoginController 职责分离。接口设计遵循 RESTful 风格——踢人本质上是"删除会话",所以使用 DELETE 方法。

📄 文件:src/main/java/com/example/authsatoken/controller/AdminController.java(新建)

controller 包下创建 AdminController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.authsatoken.controller;

import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/admin")
public class AdminController {

/**
* 踢掉指定账号的所有会话(全端踢出)
*/
@DeleteMapping("/users/{userId}/sessions")
public SaResult kickoutAll(@PathVariable Long userId) {
StpUtil.kickout(userId);
return SaResult.ok("已将账号 " + userId + " 踢下线");
}

/**
* 踢掉指定账号的指定设备类型
*/
@DeleteMapping("/users/{userId}/sessions/{device}")
public SaResult kickoutDevice(@PathVariable Long userId, @PathVariable String device) {
StpUtil.kickout(userId, device);
return SaResult.ok("已将账号 " + userId + " 在设备 " + device + " 上踢下线");
}

/**
* 根据 Token 值踢掉指定会话
*/
@DeleteMapping("/sessions/{token}")
public SaResult kickoutToken(@PathVariable String token) {
StpUtil.kickoutByTokenValue(token);
return SaResult.ok("已将 Token " + token + " 踢下线");
}
}

注意接口设计的几个细节:

路径使用资源嵌套结构 /admin/users/{userId}/sessions,语义清晰——“管理员操作某个用户的会话”。DELETE 方法表示删除资源。@PathVariable 从路径中提取参数,而不是用查询参数。

三个接口覆盖了三种踢人粒度:按账号全端踢出、按账号 + 设备类型定向踢出、按 Token 值精确踢出。在基础篇中,我们只实现了"按 Token 加入黑名单"一种方式,按设备类型踢出完全没有涉及。


2.3. NotLoginException 场景值与全局异常处理

被踢下线的用户再次访问接口时,Sa-Token 会抛出 NotLoginException。如果不做处理,Spring Boot 会返回默认的 Whitelabel Error Page。我们需要一个全局异常处理器来统一捕获并返回规范的 JSON 响应。

先了解 NotLoginException 的全部场景值:

场景值常量名含义
-1NOT_TOKEN请求中没有携带 Token
-2INVALID_TOKENToken 不存在或已被注销
-3TOKEN_TIMEOUTToken 超过有效期
-4BE_REPLACED被新登录顶替
-5KICK_OUT被管理员踢出
-6TOKEN_FREEZEToken 超过活跃频率被冻结
-7NO_PREFIXToken 未携带配置要求的前缀

📄 文件:src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(新建)

com.example.authsatoken 包下新建 exception 目录,然后创建 GlobalExceptionHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.authsatoken.exception;

import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
* 全局异常处理器 —— 统一捕获 Sa-Token 抛出的认证异常
*/
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(NotLoginException.class)
public SaResult handleNotLoginException(NotLoginException notLoginException) {
String message = switch (notLoginException.getType()) {
case NotLoginException.NOT_TOKEN -> "未提供 Token,请先登录";
case NotLoginException.INVALID_TOKEN -> "Token 无效,请重新登录";
case NotLoginException.TOKEN_TIMEOUT -> "Token 已过期,请重新登录";
case NotLoginException.BE_REPLACED -> "您的账号已在其他设备登录,当前会话已下线";
case NotLoginException.KICK_OUT -> "您已被管理员强制下线";
case NotLoginException.TOKEN_FREEZE -> "Token 已被冻结,请重新登录";
case NotLoginException.NO_PREFIX -> "未按照指定前缀提交 Token";
default -> "当前会话未登录";
};
return SaResult.error(message).setCode(401);
}
}

这里使用了 Java 17 的 switch 表达式,比传统的 switch-case-break 更简洁。@RestControllerAdvice 让这个类成为全局异常处理器,@ExceptionHandler(NotLoginException.class) 指定只捕获 NotLoginException

重启项目后验证效果:

步骤 1:登录

1
POST http://localhost:8081/auth/login?username=user&password=123456

步骤 2:踢人下线

1
DELETE http://localhost:8081/admin/users/10001/sessions

步骤 3:用被踢的 Token 访问鉴权接口

1
GET http://localhost:8081/auth/check

预期响应:

1
2
3
4
5
{
"code": 401,
"msg": "您已被管理员强制下线",
"data": null
}

如果是未携带 Token 直接访问,返回的则是 "未提供 Token,请先登录"。不同场景值,不同提示——这是基础篇的黑名单方案做不到的。

在实际项目中,你可以在这个异常处理器中同时捕获 NotPermissionException、NotRoleException 等其他 Sa-Token 异常,我们会在番外篇(三)中讲解。


2.4. 本章小结

本章区分了 Sa-Token 的三种下线方式(logout / kickout / replaced),创建了 RESTful 风格的 AdminController 实现管理员踢人功能,并通过全局异常处理器将 NotLoginException 的七大场景值转化为差异化的中文提示。

要点何时使用关键动作
logout / kickout / replaced设计下线策略时根据触发者选择对应方法
AdminController RESTful 接口管理员需要踢人下线时DELETE /admin/users/{id}/sessions
GlobalExceptionHandler项目需要统一错误响应时通过场景值返回差异化提示

第三章. 会话查询与设备管理

登录和下线都搞定了,接下来是另一类常见需求:查看当前账号在哪些设备上登录了?能不能远程踢掉某个设备?

在基础篇中,我们为此在 TokenRedisManager 中用 ZSet 存储设备信息,在 AuthService 中遍历解析 DeviceInfo 列表,两个类协作才完成设备列表查询。在 Sa-Token 中,一个 API 就够了。

3.1. 查询会话列表

我们需要在 LoginController 中新增两个查询接口:一个查当前登录用户自己的会话列表,一个按用户 ID 查询(供管理后台使用)。

📄 文件:src/main/java/com/example/authsatoken/controller/LoginController.java(修改)

在已有方法下方追加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 查询当前登录账号的所有 Token 列表
*/
@GetMapping("/tokenList")
public SaResult tokenList() {
List<String> tokenList = StpUtil.getTokenValueListByLoginId(StpUtil.getLoginId());
return SaResult.data(tokenList);
}

/**
* 查询指定账号的所有会话列表(管理视角)
*/
@GetMapping("/users/{userId}/sessions")
public SaResult getSessionList(@PathVariable Long userId) {
List<String> tokenList = StpUtil.getTokenValueListByLoginId(userId);
return SaResult.data(tokenList);
}

别忘了添加 import java.util.List;

StpUtil.getTokenValueListByLoginId() 返回指定账号当前持有的所有 Token 列表。每个 Token 代表一个活跃的登录会话。如果用户在两个设备上登录了(is-concurrent=true, is-share=false),列表就包含两个 Token 值。

重启项目后验证:先以 PC 和 APP 两种设备类型各登录一次,然后访问 /auth/tokenList——你会看到两个不同的 Token 值。


3.2. 远程踢出指定设备

有了会话列表和第二章的 AdminController,我们可以串联出一个完整的设备管理流程:

  1. 用户在"账号安全"页面调用 GET /auth/tokenList 获取自己的 Token 列表
  2. 前端展示设备列表,每个 Token 对应一个设备
  3. 用户点击"下线",前端调用 DELETE /admin/sessions/{token} 踢掉该设备

这个流程和基础篇中的"远程踢出设备"功能完全对应。但对比代码量:基础篇需要 DeviceInfo 模型、TokenRedisManager.getDeviceList()AuthService.kickDevice()AuthController.kickDevice() 四个类协作;Sa-Token 只需要两个现成的 API。

如果需要展示设备的详细信息(登录时间、设备名称等),可以在登录时将信息存入 Session,我们会在后续篇章讲解。


3.3. 本章小结

本章实现了会话列表查询和远程踢出指定设备,完成了基础篇设备管理模块的全部复刻。

要点何时使用关键动作
getTokenValueListByLoginId()需要展示设备列表时获取指定账号的所有活跃 Token
GET /auth/users/{id}/sessions管理后台查询用户会话时RESTful 资源路径 + PathVariable
查询 + 踢出串联用户远程管理自己的设备时列表接口 + DELETE 接口组合使用

第四章. 基础篇 vs Sa-Token:全面对比

经过前三章的实战,我们已经用 Sa-Token 复刻了基础篇登录认证模块的所有核心功能。现在做一次系统性的对比——不是为了"踩"手写方案,而是让两种方案的优势和教学价值都更加清晰。

4.1. 功能对比

功能基础篇实现方式Sa-Token 实现方式
用户登录JwtUtil.generateToken() + TokenRedisManager.storeToken()StpUtil.login(id)
用户注销Token 加入黑名单 + 从 ZSet 移除StpUtil.logout()
全设备注销遍历 ZSet 逐个加入黑名单StpUtil.logout(id)
按设备类型注销❌ 未实现StpUtil.logout(id, "PC")
踢人下线Token 加入黑名单(无法区分场景)StpUtil.kickout(id),场景值 -5
顶替下线ZSet 顶号逻辑(无法区分场景)自动触发,场景值 -4
多端登录控制ZSet 大小判断 + 手动顶号is-concurrent 配置项
同端互斥登录❌ 未实现StpUtil.login(id, "PC")
设备数量限制ZSet 大小判断 + 移除最早 Token + 加入黑名单SaLoginParameter.setMaxLoginCount(3)
设备列表查询遍历 ZSet + 解析 DeviceInfoStpUtil.getTokenValueListByLoginId(id)
Token 黑名单BlacklistRedisManager(手动管理 TTL)框架内部自动管理
异常场景区分❌ 所有失败统一返回"Token 无效"7 种场景值,差异化提示

12 个功能点中,基础篇有 3 个完全未实现,其余 9 个都需要大量手写代码。Sa-Token 全部支持,且大多数只需要一行代码或一个配置项。


4.2. 代码量对比

基础篇(auth 项目)

文件职责核心代码行数
RsaKeyManager.javaRSA 密钥加载~60 行
JwtUtil.javaToken 生成与解析~80 行
JwtProperties.javaJWT 配置绑定~30 行
TokenRedisManager.javaToken 存储(ZSet)~120 行
BlacklistRedisManager.java黑名单管理~50 行
RedisKeyConstants.javaRedis Key 常量~20 行
AuthToken.java双令牌模型~30 行
DeviceInfo.java设备信息模型~40 行
DeviceInfoExtractor.java设备信息提取~50 行
AuthService.java认证 Facade~150 行
AuthController.java认证控制器~80 行
合计11 个 Java 文件~710 行

Sa-Token 方案(auth-satoken 项目,截至本篇)

文件职责核心代码行数
LoginController.java登录 / 注销 / 查询~60 行
AdminController.java管理员踢人下线~30 行
GlobalExceptionHandler.java全局异常处理~25 行
合计3 个 Java 文件~115 行

11 个文件 710 行 vs 3 个文件 115 行,代码量减少约 84%。而且 Sa-Token 方案的功能覆盖面更广。


4.3. 扩展性对比

用三个真实需求场景来验证扩展性差异:

场景一:新增"7 天免登录"

基础篇需要修改 JwtProperties(增加配置项)、JwtUtil(支持动态有效期)、TokenRedisManager(ZSet score 计算),三个文件联动。Sa-Token 只需要在登录时传入参数:

1
2
3
4
StpUtil.login(10001, new SaLoginParameter()
.setTimeout(60 * 60 * 24 * 7) // 7 天有效期
.setIsLastingCookie(true) // 持久 Cookie
);

场景二:新增"管理员单端、普通用户多端"

基础篇需要在 AuthService 中根据角色走不同分支,修改顶号策略,核心逻辑高度耦合。Sa-Token 在第一章中已经实现了——就是 SaLoginParameter 的三元表达式。

场景三:新增"按设备类型踢出"

基础篇需要在 ZSet 中存储设备信息并做过滤查询,改动量大且风险高。Sa-Token 已经内置了 StpUtil.kickout(id, "PC"),零改动。

每个扩展场景,基础篇都需要修改多个文件,Sa-Token 只需要调整登录参数或调用现成 API。这就是框架封装带来的扩展性优势。

手写 vs 框架
2026-01-01 10:00
S

老师,既然 Sa-Token 这么方便,那基础篇的手写代码是不是白学了?

T
teacher

恰恰相反。正是因为你亲手写过 ZSet 顶号逻辑,你才能理解 maxLoginCount 背后做了什么。正是因为你手写过黑名单 TTL 计算,你才能理解 kickout 和 logout 为什么要区分场景值。框架帮你省掉的是重复劳动,但理解原理的能力是框架给不了你的。

S

明白了,手写是为了理解,框架是为了效率。

T
teacher

对。而且当框架出了 Bug 或者不满足需求时,你有能力看懂源码、定位问题、甚至自己扩展。这就是手写轮子的长期价值。


4.4. 本章小结

本章从功能覆盖、代码量、扩展性三个维度做了全面对比。Sa-Token 在 12 个功能点上全部支持(基础篇有 3 个未实现),代码量减少约 84%(710 行 → 115 行),新增需求时只需调整参数而无需修改多个文件。

要点何时使用关键动作
功能对比表技术选型或方案评审时逐项对比手写方案和框架方案
代码量对比评估开发效率时关注文件数量和核心代码行数
扩展性对比评估长期维护成本时用"新增需求"场景验证改动范围