登录注册番外篇(一) - 2026 年 Sa-Token 快速入门之基础登录


第一章. 回头看:我们造了多少轮子

环境版本

组件版本
JDK17
Spring Boot3.4.x
Sa-Token1.44.0
Redis7.x
Maven3.9.x

阶段式学习路径

本篇是番外篇系列的第一站。在基础篇中,我们花了六章的篇幅,从零手写了一套完整的认证内核——RSA 加密、JJWT 双令牌、Redis 黑名单、多设备管理。现在,我们暂停主线,换一个全新的视角:如果有一个框架,能用一行代码完成登录,我们还需要手写那么多东西吗?

若你对登录注册系列基础篇感兴趣,请跳转至:

本篇将带你认识 Sa-Token 框架,搭建全新的独立项目,并体验它的核心登录 API 与 Redis 集成能力。在正式认识 Sa-Token 之前,我们先做一件事——回头看看基础篇六章走下来,我们到底写了多少代码。这不是为了否定之前的努力,恰恰相反,正是因为我们亲手造过这些轮子,才能真正理解框架帮我们省掉了什么。

1.1. 基础篇的六大手写模块回顾

让我们快速盘点一下基础篇的产出。六章内容,我们在 auth 项目中构建了一个三模块的认证系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
auth/
├── auth-common/ # 公共基础模块
│ ├── Result.java # 统一响应封装
│ └── SnowflakeIdGenerator.java # 雪花算法 ID 生成器
├── auth-core/ # 认证核心模块
│ ├── RsaKeyManager.java # RSA 密钥加载与管理
│ ├── JwtUtil.java # JJWT 0.12 Token 生成与解析
│ ├── JwtProperties.java # JWT 配置属性绑定
│ ├── RedisKeyConstants.java # Redis Key 命名常量
│ ├── TokenRedisManager.java # Token 存储(ZSet 实现)
│ ├── BlacklistRedisManager.java # 黑名单管理(String + TTL)
│ ├── AuthToken.java # 双令牌模型
│ ├── DeviceInfo.java # 设备信息模型
│ ├── DeviceInfoExtractor.java # 设备信息提取工具
│ └── AuthService.java # 认证 Facade 服务
├── auth-web/ # Web 应用模块
│ ├── AuthApplication.java # 启动类
│ └── AuthController.java # 认证控制器
└── resources/
├── application.yml
└── certs/
├── private_key.pem # RSA 私钥
└── public_key.pem # RSA 公钥

14 个 Java 源文件,2 个密钥文件,1 个配置文件。这还只是一个"登录注册"功能——没有涉及权限校验,没有涉及角色管理,没有涉及路由拦截。


1.2. 手写轮子的三大代价

基础篇的每一行代码都有它的教学价值,但如果把这套代码直接搬进生产环境,我们会面临三个现实问题。

代价一:维护成本远超预期

JwtUtil 需要处理 Token 过期、签名验证、Claims 解析等逻辑;TokenRedisManager 需要管理 ZSet 的 score 计算、过期清理、Pipeline 批量操作;BlacklistRedisManager 需要精确计算每个 Token 的剩余 TTL。这些工具类一旦出现 Bug,排查成本很高——因为没有社区帮你验证,所有边界情况都需要自己覆盖。

代价二:安全性依赖个人经验

我们手写的 RSA 密钥加载逻辑、Token 生成规则、黑名单过期策略,都是基于个人对安全的理解来实现的。但安全领域有一条铁律:不要自己发明加密方案。一个成熟的框架背后有数千个 Issue 和 PR 的打磨,这种安全性是个人项目很难达到的。

代价三:功能扩展举步维艰

现在产品经理提了一个需求:"同一个账号在手机端和电脑端可以同时登录,但同一端只能有一个设备在线。"要实现这个需求,我们需要修改 TokenRedisManager 的 ZSet 逻辑,修改 AuthService 的登录方法,修改 DeviceInfoExtractor 的设备识别规则,还要在 AuthController 中新增接口。一个需求牵动四个类,这就是手写轮子的扩展代价。

手写轮子的价值在于理解原理,但生产环境中,我们需要站在巨人的肩膀上。


第二章. 认识 Sa-Token:一行代码解决登录

基础篇让我们深刻体会到了手写认证系统的复杂度。那么 Java 生态中,有没有一个框架能大幅降低这种复杂度,同时又不像 Spring Security 那样需要理解一整套过滤器链才能上手?答案是 Sa-Token。

2.1. Sa-Token 是什么

Sa-Token轻量级 Java 权限认证框架,由国内开发者 shengzhang_ 主导开发dromara由国内知名开源开发者组成的开源社区 开源社区下的一个项目,目前在 GitHub 上拥有超过 18,000 个 Star。它的官方定位是:

一个轻量级 Java 权限认证框架,让鉴权变得简单、优雅。

这句话的关键词是"轻量级"和"简单"。我们可以通过一个最直观的例子来感受——在 Sa-Token 中,完成用户登录只需要一行代码:

1
2
3
// 参数是用户 ID,可以是 long、int、String 等任意类型
// Sa-Token 内部会统一转换为 String 进行存储
StpUtil.login(10001);

没有 JwtUtil,没有 RsaKeyManager,没有 TokenRedisManager。一行代码,框架自动完成了 Token 生成、Session 创建、Cookie 写入(或响应头返回)等全部工作。

让我们看看 Sa-Token 的五大核心模块,对它的能力范围建立一个全局认知:

image-20260208211535983

模块解决的问题本系列是否覆盖
登录认证用户登录、注销、Token 管理、多设备登录✅ 本篇开始
权限认证角色校验、权限码校验、注解鉴权、路由拦截✅ 后续篇章
单点登录(SSO)多系统共享登录态❌ 不在本系列范围
OAuth2.0第三方授权登录❌ 进阶篇单独讲解
微服务网关鉴权Gateway 层统一鉴权❌ 不在本系列范围

Sa-Token 的设计哲学是"一个方法解决一个问题",API 命名直白到几乎不需要查文档。


2.2. 为什么选 Sa-Token 而不是 Spring Security

你可能会想:Java 安全领域的"正统"不是 Spring Security 吗?为什么我们先讲 Sa-Token?这个问题很好,我们从三个维度来回答。

学习曲线

Spring Security 的核心是一条 过滤器链由多个安全过滤器串联组成的请求处理链,每个请求都必须经过所有过滤器。要理解它,你需要先搞清楚 SecurityFilterChainAuthenticationManagerAuthenticationProviderUserDetailsServiceSecurityContextHolder 这一整套概念,然后才能写出第一个自定义登录逻辑。

Sa-Token 的学习路径则完全不同。它没有过滤器链的概念,所有操作都通过一个静态工具类 StpUtil 完成。你不需要理解任何底层架构,打开 API 文档,找到你需要的方法,直接调用就行。

官方文档链接请跳转至:https://sa-token.cc/doc.html#/

学习曲线对比
2026-01-01 10:00
S

老师,Spring Security 和 Sa-Token 上手难度差多少?

T
teacher

打个比方,Spring Security 像是一辆手动挡赛车,性能强悍但你得先学会换挡。Sa-Token 像是一辆自动挡家用车,上车就能开。

S

那 Sa-Token 是不是功能比较弱?

T
teacher

不是。它覆盖了登录认证、权限校验、多设备管理、踢人下线等绝大多数场景。只是在 OAuth2 和微服务网关这种重度企业场景下,Spring Security 的生态更成熟。

代码量

我们在基础篇中用了 14 个 Java 文件来实现登录认证。使用 Sa-Token 之后,同样的功能大约只需要 3~4 个文件。这不是因为 Sa-Token “偷工减料”,而是因为它把 Token 生成、Session 管理、Redis 存储这些通用逻辑全部内置了,我们只需要关注业务本身。

适用场景

Sa-Token 最适合的场景是:中小型项目的快速开发、前后端分离架构、需要快速原型验证的团队、不想被框架"绑架"的开发者(Sa-Token 的侵入性极低)。而 Spring Security 更适合:需要深度定制安全策略的企业级项目、已经深度使用 Spring 生态的团队、需要 OAuth2 Authorization Server 的场景。

本章介绍了 Sa-Token 的定位、五大能力模块,并从学习曲线、代码量、适用场景三个维度明确了它与 Spring Security 的差异化选择依据。

要点何时使用关键动作
Sa-Token 能力边界技术选型时确认覆盖范围核对五大模块是否满足业务需求
vs Spring Security团队评审框架选型时用学习曲线 + 场景复杂度做决策
StpUtil.login(id) 的本质理解框架核心时一行代码背后是 Token + Session + 存储三步

2.3. 本节总结

本节回顾

本章建立了对 Sa-Token 的全局认知。我们了解了它归属于 dromara 开源社区,定位是轻量级 Java 权限认证框架,核心 API StpUtil.login(id) 只需一行便能完成基础篇需要多个类才能完成的登录流程。通过五大模块表,我们明确了本系列的覆盖边界:登录认证和权限认证是重点,SSO、OAuth2、微服务网关鉴权不在本系列范围内。通过与 Spring Security 的对比,我们明确了 Sa-Token 在中小项目快速开发场景下的优势,以及 Spring Security 在企业级深度定制场景下的不可替代性。

核心汇总表

对比维度Sa-TokenSpring Security
上手门槛无需理解底层架构,API 直白需要理解过滤器链和 Provider 体系
核心操作StpUtil.login(id) 等静态方法实现 UserDetailsService 等接口
适合场景中小项目、快速开发企业级定制、OAuth2 授权服务器
侵入性极低较高,深度依赖 Spring 生态

第三章. 环境搭建与项目初始化

认识了 Sa-Token 的定位和能力之后,是时候动手了。本章我们将从零创建一个全新的独立项目,引入 Sa-Token 的核心依赖,并完成基础配置。和基础篇不同,这次我们不再使用多模块架构。Sa-Token 的一大优势就是轻量,我们用一个单模块项目就能承载所有功能,让你把注意力完全放在框架本身。

3.1. 新建 Maven 项目并引入依赖

我们创建一个名为 auth-satoken 的全新项目,与基础篇的 auth 项目完全独立。

步骤 1:通过 IDEA 创建项目

打开 IntelliJ IDEA,选择 File → New → Project,在左侧选择 Spring Initializr,填写以下信息:

  • Nameauth-satoken
  • Groupcom.example
  • Artifactauth-satoken
  • Type:Maven
  • Language:Java
  • JDK:17
  • Java:17
  • Packaging:Jar

在依赖选择页面,勾选以下两项:

  • Spring Web
  • Spring Data Redis

点击 Create 完成创建。操作成功后,IDEA 会自动打开新项目窗口,底部状态栏显示 Maven 依赖正在下载,等待进度条消失即可。

如果你更习惯手动创建,也可以访问 https://start.spring.io 生成项目骨架后导入 IDEA。

步骤 2:确认项目结构

创建完成后,项目的初始结构如下:

1
2
3
4
5
6
7
8
9
auth-satoken/
├── pom.xml
└── src/
└── main/
├── java/
│ └── com/example/authsatoken/
│ └── AuthSatokenApplication.java
└── resources/
└── application.yml

步骤 3:引入 Sa-Token 和 Hutool 依赖

Spring Initializr 只生成了 spring-boot-starter-webspring-boot-starter-data-redis,我们还需要手动追加 Sa-Token 相关依赖。打开 pom.xml,在 <dependencies> 节点中追加以下内容:

📄 文件:pom.xml(修改)

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
<!-- Sa-Token 权限认证(SpringBoot3 专用 Starter) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.44.0</version>
</dependency>

<!-- Sa-Token 整合 Redis(使用 Jackson 序列化) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.44.0</version>
</dependency>

<!-- Redis 连接池(sa-token-redis-jackson 的底层依赖) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

<!-- Hutool 工具库(提供加密、JSON、字符串等常用工具) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.34</version>
</dependency>

这里有几个关键点需要说明。

sa-token-spring-boot3-starter 是 Sa-Token 为 Spring Boot 3.x 提供的专用启动器。如果你使用的是 Spring Boot 2.x,需要将 boot3 改为 boot,即 sa-token-spring-boot-starter。两者的 API 完全一致,只是底层适配的 Servlet 版本不同。

sa-token-redis-jackson 是 Sa-Token 的 Redis 集成插件。引入这个依赖后,Sa-Token 会通过 Spring Boot 的自动装配机制检测到 Redis 的存在,并将所有会话数据的存储层从默认的 JVM 内存切换到 Redis,不需要我们编写任何额外的配置类。它依赖 commons-pool2 作为 Redis 连接池,所以需要一并引入,否则启动时会报 NoClassDefFoundError

hutool-all 是 Hutool 的全量包,后续章节中我们会用到它的加密工具和 JSON 处理能力。

追加完成后,点击 IDEA 右侧的 Maven 面板,点击刷新图标(Reload All Maven Projects)。等待底部进度条走完,确认 pom.xml 中没有红色报错线,说明依赖下载成功。


3.2. 配置 application.yml

依赖引入完成后,我们需要在 application.yml 中完成两件事:配置 Sa-Token 的核心参数,以及配置 Redis 连接信息。

📄 文件:src/main/resources/application.yml(修改)

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
server:
port: 8081 # 使用 8081 端口,避免与基础篇的 8080 冲突

# Sa-Token 核心配置
sa-token:
# Token 名称(同时决定 Cookie 名称、请求头名称、URL 参数名称)
token-name: satoken
# Token 有效期,单位:秒。-1 表示永不过期(生产环境不推荐)
timeout: 2592000
# Token 最低活跃频率,单位:秒。-1 表示不限制
active-timeout: -1
# 是否允许同一账号多端同时登录
is-concurrent: true
# 在多端登录下,是否共用同一个 Token
is-share: true
# Token 风格:uuid / simple-uuid / random-32 / random-64 / random-128 / tik
token-style: uuid
# 是否在启动时在控制台打印版本字符画
is-print: true

# Redis 连接配置
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: # 留空表示无密码,生产环境务必设置密码
database: 0 # 使用 0 号数据库
要点何时使用关键动作
sa-token-spring-boot3-starterSpring Boot 3.x 项目引入 Sa-Token注意 boot3 与 boot 的版本区分
sa-token-redis-jackson需要将会话持久化到 Redis 时引入后自动切换存储层,无需额外配置
is-concurrent + is-share 组合项目初始化时确定登录策略根据业务选择,可在登录时被参数覆盖
配置项推荐值控制内容
token-namesatokenCookie 名 / Header 名 / URL 参数名
timeout2592000(30 天)Token 最大存活时间
is-concurrenttrue(多端)/ false(单端)是否允许同一账号多端同时在线
is-share根据业务决定多端登录时是否复用同一 Token

第四章. 一行代码登录:StpUtil 与 SaResult 核心解析

项目搭建完成,配置也就绪了。现在我们终于可以写代码了。在动手之前,有两个 Sa-Token 的核心类必须先认识清楚——我们接下来所有的接口都会用到它们,但如果不理解它们的设计,你只会"照着抄"而不是"真的懂"。

4.1. StpUtil:Sa-Token 的操作中枢

StpUtil 是 Sa-Token 中所有操作的入口,你会发现本系列中几乎所有认证操作都从它开始。它是一个全静态方法的工具类,这意味着你不需要通过 @Autowired 注入就能直接调用,就像使用 Math.random() 一样。

但你可能会好奇:一个静态方法,是怎么知道"当前这个请求的用户是谁"的?答案是 Sa-Token 在框架层面拦截了每一个进入的 HTTP 请求,从请求中提取 Token(Cookie 或 Header),并将其存储在当前线程的 ThreadLocal线程本地存储,每个线程独立持有一份数据副本,线程间互不干扰 中。这样,当你在业务代码中调用 StpUtil.getLoginId() 时,它实际上是在从当前线程的 ThreadLocal 里取出 Token,再去存储层(Redis)反查用户 ID,整个过程对业务代码完全透明。

Stpt(token)+ p(permission)缩写演化而来的历史命名,你不需要深究它的含义,记住"Sa-Token 的静态操作入口"这个定位就够了。


4.2. SaResult:Sa-Token 内置的统一响应类

在写第一个接口之前,还有一个类需要先了解——SaResult,它是 Sa-Token 内置的统一 HTTP 响应封装类,结构和我们在基础篇手写的 Result 几乎完全一致。

SaResult 有三个核心字段:

字段类型含义
codeint状态码,默认 200 表示成功,500 表示失败
msgString提示信息
dataObject响应数据,可以是任意类型

常用的静态工厂方法如下:

1
2
3
4
5
SaResult.ok("操作成功");              // code=200, msg="操作成功", data=null
SaResult.ok("操作成功").setData(obj); // code=200, msg="操作成功", data=obj
SaResult.data(obj); // code=200, msg="ok", data=obj
SaResult.error("出错了"); // code=500, msg="出错了", data=null
SaResult.error("出错了").setCode(401); // code=401, msg="出错了", data=null

值得注意的是,setCode()setMsg()setData() 都返回 SaResult 本身,支持链式调用。在实际项目中,你完全可以继续使用自己的 Result 类,SaResult 不是强制的——本篇为了减少额外的脚手架代码,直接使用它。


4.3. 登录、注销与状态查询

理解了 StpUtilSaResult 的设计之后,我们来写第一个功能完整的控制器。

首先在 com.example.authsatoken 包下新建 controller 子包,然后创建 LoginController.java

📄 文件: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
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.authsatoken.controller;

import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/auth")
public class LoginController {

/**
* 登录接口
* 实际项目中应查询数据库校验凭据,这里用硬编码模拟,仅聚焦 Sa-Token 本身的行为
*/
@PostMapping("/login")
public SaResult login(@RequestParam String username, @RequestParam String password) {
if ("admin".equals(username) && "123456".equals(password)) {
// 核心:指定用户 ID,Sa-Token 负责生成 Token、创建会话、写入响应
// 任何可以唯一标识用户的值都可以作为参数,框架内部统一转为 String 存储
StpUtil.login(10001);
// 返回给前端的 Token 值,前端后续请求需携带此值
return SaResult.ok("登录成功").setData(StpUtil.getTokenValue());
}
return SaResult.error("用户名或密码错误");
}

/**
* 注销接口:注销当前请求携带的 Token,Redis 中对应的会话数据随即清除
*/
@PostMapping("/logout")
public SaResult logout() {
StpUtil.logout();
return SaResult.ok("注销成功");
}

/**
* 查询当前请求携带的 Token 是否处于有效登录状态
*/
@GetMapping("/isLogin")
public SaResult isLogin() {
return SaResult.ok("是否已登录:" + StpUtil.isLogin());
}
}

这段代码的核心是 StpUtil.login(10001) 这一行。当它被执行时,Sa-Token 在背后依次完成了:

根据配置的 token-style 生成一个 Token 字符串(本项目配置的是 UUID 格式),以用户 ID 10001 为键在 Redis 中创建 Session 会话,建立 Token 与 Session 之间的映射关系,最后将 Token 写入响应(默认同时写 Cookie 和响应头,以适配不同前端场景)。

登录成功后,我们通过 StpUtil.getTokenValue() 取到刚刚生成的 Token 并放入响应体的 data 字段中返回给前端。前端拿到这个值后,后续所有需要身份认证的请求,都需要在 HTTP 请求头中携带:

1
satoken: 1db92b69-74ce-4c5f-a838-13c0f414d047

其中 satoken 就是我们在 application.yml 中配置的 token-name

关于 Token 的两种传递方式

Sa-Token 支持两种 Token 传递方式,对应不同的前端场景:

浏览器在登录成功后自动保存 Cookie,后续请求自动携带,无需前端额外代码。适合传统 Web 页面(如 Thymeleaf 渲染的服务端页面)。Sa-Token 会在 Set-Cookie 响应头中自动写入 Token,浏览器的每次请求都会自动带上。

登录成功后,前端从响应体的 data 字段取出 Token 并保存到 localStorage 或 Vuex。之后每次 Ajax 请求时,在请求头中手动携带:

1
axios.defaults.headers.common['satoken'] = localStorage.getItem('token');

这是目前最主流的前后端分离方式,也是我们后续测试时的默认模式。


4.4. Token 信息获取

登录成功后,我们通常还需要获取当前用户的详细 Token 信息和登录 ID。Sa-Token 为此提供了一组简洁的 API。

LoginController 中,在 isLogin() 方法下方追加以下两个方法:

📄 文件: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
/**
* 获取当前会话的 Token 完整信息,调试时非常有用
*/
@GetMapping("/tokenInfo")
public SaResult tokenInfo() {
return SaResult.data(StpUtil.getTokenInfo());
}

/**
* 获取当前登录用户的 ID
* 若未登录会抛出 NotLoginException,如需"安全获取"请用 getLoginIdDefaultNull()
*/
@GetMapping("/loginId")
public SaResult loginId() {
return SaResult.ok("当前登录用户 ID:" + StpUtil.getLoginId());
}

StpUtil.getTokenInfo() 返回一个 SaTokenInfo 对象,包含了当前会话的完整快照,以下是各字段的含义:

字段类型含义
tokenNameStringToken 名称,即 token-name 配置值
tokenValueString当前 Token 的具体值
isLoginboolean是否处于登录状态
loginIdObject登录时传入的用户 ID(以 String 形式存储)
loginTypeString登录类型,默认 login
tokenTimeoutlongToken 剩余有效期(秒),-1 表示永不过期,-2 表示已过期
sessionTimeoutlongSession 剩余有效期(秒)
loginDeviceTypeString登录设备类型,未指定时默认 DEF

StpUtil.getLoginId() 返回当前登录用户的 ID。有一个细节需要特别留意:无论登录时传入的是 long 还是 intgetLoginId() 都以 Object 类型返回,实际存储的是 String "10001" 而非 Long 10001。如果你需要精确类型,请使用:

1
2
3
4
5
6
7
8
// 返回 String 类型的 ID,是最常用的安全写法
StpUtil.getLoginIdAsString();

// 返回 Long 类型,如果无法转换会抛异常
StpUtil.getLoginIdAsLong();

// 未登录时返回 null,而不是抛出异常——适合"可选校验"场景
StpUtil.getLoginIdDefaultNull();

下面是 StpUtil 中与登录状态相关的常用 API 速查表:

方法作用未登录时的行为
StpUtil.login(id)执行登录,创建会话
StpUtil.logout()注销当前会话不报错,静默执行
StpUtil.isLogin()判断是否已登录返回 false
StpUtil.getLoginId()获取登录 ID(Object 类型)抛出 NotLoginException
StpUtil.getLoginIdAsString()获取登录 ID(String 类型)抛出 NotLoginException
StpUtil.getLoginIdDefaultNull()获取登录 ID(安全版)返回 null
StpUtil.getTokenValue()获取当前 Token 值返回 null
StpUtil.getTokenInfo()获取 Token 完整信息对象返回对象,但 isLogin=false

4.5. 启动项目并测试

代码写完了,让我们启动项目,亲手验证每个接口的行为。

步骤 1:确认 Redis 已启动

在终端中执行以下命令,确认 Redis 服务正在运行:

1
redis-cli ping

如果返回 PONG,说明 Redis 正常运行。如果提示连接失败,请先启动 Redis 服务,否则 Spring Boot 应用在启动阶段会因为无法连接 Redis 而直接报错退出。

步骤 2:启动 Spring Boot 应用

在 IDEA 中运行 AuthSatokenApplicationmain 方法。观察控制台输出,如果看到以下字符画,说明 Sa-Token 已成功加载:

1
2
3
4
5
____    ____    ______   ____    __  __   ____    _   __
/ ___| / _ | |_ _| /_ _ \ | |/ / | ___| | \ | |
\___ \ | |_| | | | | | | | | / | |___ | \| |
___) || _| | | | |__| | | _\ | |___ | |\ |
|____/ |_| |_| |_| \____/ |_| \_\ |_____| |_| \_|

同时确认控制台没有红色报错信息,并且出现了 Started AuthSatokenApplication in x.xx seconds 的提示。

步骤 3:测试登录接口

使用 Postman(或 curl)发送 POST 请求:

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

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "登录成功",
"data": "1db92b69-74ce-4c5f-a838-13c0f414d047"
}

data 字段返回的就是 Token 值,每次登录生成的值都不同(因为是 UUID)。请将这个 Token 值复制备用,后续带认证的请求都需要它。

步骤 4:测试登录状态查询

在 Postman 的请求 Header 中添加:

1
satoken: 1db92b69-74ce-4c5f-a838-13c0f414d047  (替换为你实际的 Token)

然后访问:

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

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "是否已登录:true",
"data": null
}

步骤 5:测试获取 Token 完整信息

同样携带 Token Header,访问:

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

预期响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"code": 200,
"msg": "ok",
"data": {
"tokenName": "satoken",
"tokenValue": "1db92b69-74ce-4c5f-a838-13c0f414d047",
"isLogin": true,
"loginId": "10001",
"loginType": "login",
"tokenTimeout": 2591874,
"sessionTimeout": 2591874,
"tokenSessionTimeout": -2,
"tokenActiveTimeout": -1,
"loginDeviceType": "DEF",
"tag": null
}
}

注意 loginId 字段的值是字符串 "10001" 而非数字 10001,这印证了前面说的"Sa-Token 内部统一以 String 存储用户 ID"。loginDeviceType 显示为 DEF,这是因为我们调用 StpUtil.login(10001) 时没有指定设备类型,框架使用了默认值。后续章节中我们会学习如何指定设备类型。

步骤 6:测试注销接口

携带 Token Header,访问:

1
POST http://localhost:8081/auth/logout

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "注销成功",
"data": null
}

注销后,再次访问 GET /auth/isLogin,预期返回 是否已登录:false。这说明注销操作成功将 Redis 中对应的 Token 和 Session 数据清除了。我们将在第五章中通过直接查看 Redis,来验证这个清除过程。

要点何时使用关键动作
StpUtil.login(id)用户身份校验通过后传入用户 ID,一行完成登录
StpUtil.getTokenValue()登录后将 Token 返回给前端时放入响应体 data 字段
StpUtil.getLoginIdDefaultNull()不确定当前是否已登录时安全获取 ID,避免 NotLoginException

4.6. 本节总结

本节回顾

本章完成了从零到第一个可运行认证接口的全过程。我们首先深入理解了 StpUtil 的设计本质——全静态工具类,依托框架的 ThreadLocal 机制感知当前请求的 Token,无需注入即可在任何位置调用。随后详细拆解了 SaResult 的三字段结构(code / msg / data)和五个常用工厂方法,为后续所有接口的响应格式打下基础。在 LoginController 的实现中,我们清楚解释了登录时 Sa-Token 背后的四步动作(生成 Token → 创建 Session → 建立映射 → 写入响应),以及 Token 在前后端分离场景下的 Header 传递方式。SaTokenInfo 的各字段含义、getLoginId() 的类型陷阱与安全替代方法也在本章一并覆盖。最后,六步测试流程验证了全部接口行为,并为第五章的 Redis 观察实验做好了铺垫。

核心汇总表

职责关键点
StpUtilSa-Token 全部操作的静态入口通过 ThreadLocal 感知当前请求,无需注入
SaResult统一 HTTP 响应封装code/msg/data,支持链式 setXxx()
SaTokenInfoToken 会话的完整快照loginId 以 String 存储,注意类型陷阱
方法适用场景未登录时行为
getLoginId()明确已登录的请求链路抛出 NotLoginException
getLoginIdAsString()需要 String 类型时抛出 NotLoginException
getLoginIdDefaultNull()不确定登录状态时返回 null

这个问题问得非常好,你遇到的现象其实是 Cookie 机制在背后默默工作,不是 Sa-Token “智能识别了你这个人”。让我解释清楚整个过程。


Sa-Token 是怎么在没有手动带 Header 的情况下认出你的?

第一步:登录时 Sa-Token 同时做了两件事

当你调用 /auth/login 登录成功后,Sa-Token 会把 Token 写到两个地方:

第一个地方:HTTP 响应体的 data 字段——也就是我们代码里 .setData(StpUtil.getTokenValue()) 返回的那个值,这是给前端手动保存用的。

第二个地方:HTTP 响应头的 Set-Cookie 字段——Sa-Token 默认会同时写一个 Cookie,名称就是我们配置的 token-name: satoken,值就是 Token 字符串。

你可以在 Apifox 登录接口的响应里找到这个响应头:

1
Set-Cookie: satoken=1db92b69-74ce-4c5f-a838-13c0f414d047; Path=/; HttpOnly

第二步:浏览器/Apifox 自动带上了 Cookie

Apifox 内置了一个 Cookie 管理器,行为和浏览器完全一样——收到 Set-Cookie 响应头之后,自动把这个 Cookie 存起来。下次向同一个域名(localhost:8081)发请求时,自动在请求头里附上:

1
Cookie: satoken=1db92b69-74ce-4c5f-a838-13c0f414d047

这一切都是 Apifox 在背后自动完成的,你根本看不到,也不需要手动操作。所以你"去掉 satoken 请求头"之后,Cookie 里的 Token 还在,Sa-Token 从 Cookie 里读到了,照样认出你。

第三步:Sa-Token 的 Token 读取优先级

Sa-Token 在每次请求中读取 Token 时,会按以下顺序依次尝试:

1
2
3
1. 请求头 Header(key = token-name,即 satoken)
2. Cookie(name = token-name,即 satoken)
3. URL 参数(?satoken=xxx)

所以这两种模式分别对应什么场景?

模式Token 存哪里谁来携带适合场景
Cookie 模式浏览器/客户端自动管理自动附加,无需代码传统 Web、服务端渲染页面
Header 模式前端手动存(localStorage 等)每次请求手动写入 Header前后端分离、App、小程序

Sa-Token 默认两种模式同时支持,不需要你选择,框架都会尝试读取。这就是为什么你在 Apifox 里测试时"去掉 Header 也能过"——因为 Cookie 通道还开着。

真实的前后端分离项目里,前端(Vue/React)用 axios 发请求时通常不会自动带 Cookie(跨域情况下需要额外配置),所以实际上走的是 Header 模式。Apifox 作为测试工具,默认行为更接近浏览器,所以 Cookie 模式会悄悄生效。

明白这个机制之后,后续测试时如果想模拟"纯 Header 模式",记得在 Apifox 里关掉自动 Cookie 管理就行了。

我已经仔细阅读了 Sa-Token 官方文档关于 Session 会话的全部内容,以及你现有笔记的第四章结尾。现在我来重写第五章。

第五章的核心主题应该是:Sa-Token 的三种 Session 类型(Account-Session、Token-Session、Custom-Session),以及如何在 Session 上存取数据。Redis 作为观察手段穿插其中,而不是主角。内容要无缝衔接第四章"第六步测试注销后返回 false"这个结尾。


第五章. 会话的三张面孔:Session 机制全解析

在第四章中,我们完成了第一个可运行的认证接口,并通过六步测试流程验证了登录、状态查询、注销的完整生命周期。测试的最后一步,我们发现注销后再调用 /auth/isLogin,返回值变成了 false——会话数据消失了。

但这里有一个问题值得深究:Sa-Token 所说的"会话",究竟是什么?Token 是会话吗?Session 是会话吗?它们是同一个东西吗?如果不搞清楚这个问题,后续章节中你会遇到一堆行为"符合预期但说不清为什么"的 API,这会成为你日后排查问题的最大障碍。

本章我们就把这件事彻底搞清楚。

5.1. Token 和 Session 不是同一回事

大多数初次接触 Sa-Token 的同学,会默认把 Token 和 Session 理解成"同一个东西的两种叫法"。这是一个需要在第一时间纠正的误解。

让我们用一个生活中的场景来建立直觉:你去图书馆借书,馆员给了你一张 借书证(Token)。这张借书证本身只是一张卡,上面印着你的编号,馆员通过它能查到 你的档案袋(Session)。档案袋里装着你的个人信息、当前借阅记录、历史违规记录等等。借书证是凭证,档案袋是数据载体,两者职责完全不同。

回到 Sa-Token:

  • Token 是一串字符串,是客户端(浏览器、App)持有的凭证,每次请求时附带在 Header 或 Cookie 中,框架通过它识别"这是哪个用户发来的请求"。
  • Session 是框架在服务端(Redis)维护的数据容器,可以在里面存储任意键值对,开发者可以把需要在多个请求间共享的数据放在这里。

一个用户可以有多个 Token(多设备登录),但这些 Token 最终都指向同一个用户的 Session。这就是两者的核心关系。

Token 是客户端的凭证,Session 是服务端的数据容器,两者通过用户 ID 关联,而不是一一对应。

理解了这个基础区别之后,我们来看 Sa-Token 更进一步的设计——它把 Session 分成了三种,分别对应三种不同的使用场景。


5.2. 三种 Session 的设计哲学

Sa-Token 的三种 Session 分别是 Account-Session、Token-Session 和 Custom-Session。它们不是功能上的重复,而是从三个不同的维度出发,解决三种不同的数据归属问题。

在动手写代码之前,我们先用一张表格建立全局认知:

Session 类型归属维度生命周期典型用途
Account-Session账号维度(一个用户唯一一个)与用户的登录状态同步缓存用户基本信息、权限列表等账号级数据
Token-SessionToken 维度(每个 Token 独立一个)与该 Token 的有效期同步缓存与特定终端相关的数据,如当前终端的操作记录
Custom-Session任意自定义维度由开发者手动控制为业务实体挂载临时数据,如商品、订单的缓存

接下来我们逐一深入每种 Session 的细节。


5.3. Account-Session:账号维度的数据容器

Account-Session 是三种 Session 中最常用的一种。它的核心特点只有一句话:整个账号共享一个 Session,无论该账号从几台设备登录

这意味着什么?用户 A 同时用手机和电脑登录了你的系统,产生了两个不同的 Token,但这两个 Token 背后共享同一个 Account-Session。你在手机端的 Account-Session 里存入的数据,在电脑端同样可以读取到。

5.3.1. 核心 API 与使用方式

获取 Account-Session 有几种方式,分别对应不同的调用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取当前登录账号的 Account-Session(必须在已登录状态下调用)
// 内部等价于:StpUtil.getSessionByLoginId(StpUtil.getLoginId())
StpUtil.getSession();

// 获取当前账号的 Account-Session,并决定 Session 不存在时是否自动创建
// true:不存在则新建并返回(默认行为)
// false:不存在则返回 null
StpUtil.getSession(true);

// 直接指定用户 ID 获取其 Account-Session(可在任意业务逻辑中使用,不限于当前请求)
StpUtil.getSessionByLoginId(10001);

// 指定 ID 获取,同时控制不存在时的行为
StpUtil.getSessionByLoginId(10001, false);

// 通过 SessionId 获取(SessionId 就是 "satoken:login:session:{userId}")
// Session 不存在时返回 null,不会自动创建
StpUtil.getSessionBySessionId("satoken:login:session:10001");

这些方法返回的都是 SaSession 对象,拿到之后你就可以在上面自由地存取任意键值对。

5.3.2. 在 Account-Session 上存取数据

我们来写一个具体的场景:用户登录成功后,将用户对象缓存到 Account-Session 中,后续任何接口都可以直接从 Session 中取出,而不需要每次都查数据库。

首先在 controller 包下新建 SessionDemoController.java,专门用来演示三种 Session 的操作:

📄 文件:src/main/java/com/example/authsatoken/controller/SessionDemoController.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
41
42
43
44
package com.example.authsatoken.controller;

import cn.dev33.satoken.session.SaSession;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/session")
public class SessionDemoController {
/**
* 演示向 Account-Session 写入数据
* 场景:模拟登录后将用户昵称缓存到 Account-Session
*/
@PostMapping("/account/set")
public SaResult setAccountSession() {
StpUtil.getSession()
.set("nickname", "张三")
.set("role", "admin")
.set("loginCount", 1);
return SaResult.ok("已向 Account-Session 写入数据");
}

/**
* 演示从 Account-Session 读取数据
* 无论当前请求携带的是哪个设备的 Token,读取的都是同一份数据
*/
@GetMapping("/account/get")
public SaResult accountGet() {
SaSession session = StpUtil.getSession();
// 基础取值:返回 Object 类型
Object nickname = session.get("nickname");
// 带类型转换的取值
String role = session.getString("role");
int loginCount = session.getInt("loginCount");
// 带默认值的取值:若 key 不存在,返回指定的默认值而不是 null
String email = session.get("email", "暂未设置");
return SaResult.ok("读取成功").setData(
"昵称=" + nickname + ", 角色=" + role +
", 登录次数=" + loginCount + ", 邮箱=" + email
);
}

}

步骤 1: 确保已登录(参考第四章,调用 /auth/login 获取 Token),然后携带 Token 请求:

1
POST http://localhost:8081/session/account/set

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "已向 Account-Session 写入数据",
"data": null
}

步骤 2: 随后请求读取接口:

1
GET http://localhost:8081/session/account/get

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "读取成功",
"data": "昵称=管理员小王, 角色=admin, 登录次数=1, 邮箱=暂未设置"
}

email 键我们从未设置过,所以 session.get("email", "暂未设置") 触发了默认值逻辑,返回了 "暂未设置" 而不是 null。这是一个非常实用的防空指针技巧,建议在实际项目中优先使用带默认值的取值方法。

5.3.3. 用 Redis 验证"账号共享"的本质

前面我们说 Account-Session 是账号维度的——整个账号只有一个 Session,多个 Token 共享它。现在我们用 Redis 来亲眼验证这一点。

重新登录,这次我们登录两次,模拟同一账号的两台设备:

1
2
3
4
5
6
7
# 第一次登录(模拟手机端)
POST http://localhost:8081/auth/login?username=admin&password=123456
# 假设返回 Token-A:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa

# 第二次登录(模拟电脑端)
POST http://localhost:8081/auth/login?username=admin&password=123456
# 假设返回 Token-B:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb

此时打开 Redis 客户端执行 KEYS *,你会看到:

1
2
3
1) "satoken:login:token:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
2) "satoken:login:token:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
3) "satoken:login:session:10001"

三个 Key,两个 Token Key,但只有一个 Session Key。satoken:login:session:10001 这个 Key 以用户 ID 为后缀,不随 Token 的数量变化——这就是"账号维度"的真实含义。

查看这个 Session 的内容:

1
> GET "satoken:login:session:10001"

你会在 terminalList 字段中看到两条终端记录,分别对应 Token-A 和 Token-B,它们都挂载在同一个 Session 对象下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"type": "Account-Session",
"loginId": 10001,
"terminalList": [
{
"tokenValue": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"deviceType": "DEF",
"createTime": 1772518275240
},
{
"tokenValue": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"deviceType": "DEF",
"createTime": 1772518276100
}
]
}

现在用 Token-A 携带请求调用 /session/account/set 写入昵称,再换用 Token-B 携带请求调用 /session/account/get 读取,结果是完全一致的。这就是 Account-Session 账号级共享的直观证明。

5.3.4. 懒加载取值:get 方法的第三种重载

SaSessionget 方法有一个非常实用但容易被忽略的重载——接受一个 Supplier 函数作为参数:

1
2
3
4
5
6
7
// 若 Session 中已有 "userInfo" 这个 key,直接返回缓存值
// 若没有,执行 Supplier 获取新值,存入 Session,然后返回
Object userInfo = session.get("userInfo", () -> {
// 这里写"缓存未命中时的数据获取逻辑",比如查数据库
// 在本演示中我们用一个 Map 模拟数据库返回的用户对象
return java.util.Map.of("id", 10001, "name", "管理员小王");
});

这个写法实现了一个经典的缓存模式:先查缓存,未命中再查数据库,查到后自动写入缓存。在实际项目中,你可以用它替代以下冗长的模板代码:

1
2
3
4
5
6
7
8
9
10
// ❌ 没有懒加载时需要这样写
Object userInfo = session.get("userInfo");
if (userInfo == null) {
userInfo = userService.findById(10001); // 查数据库
session.set("userInfo", userInfo); // 写入缓存
}
return userInfo;

// ✅ 使用懒加载重载,等价于上面的代码
Object userInfo = session.get("userInfo", () -> userService.findById(10001));

两者行为完全一致,后者的代码量减少了三分之二,可读性也更高。

5.3.5. 本节小结

本节完成了 Account-Session 的 API 学习与 Redis 数据结构验证,亲眼确认了同一账号多设备登录时共享同一个 Session 对象的行为。

要点何时使用关键动作
StpUtil.getSession()当前请求上下文中操作账号 Session必须在已登录状态下调用,否则抛出异常
session.get(key, supplier)实现查缓存→查库→写缓存的懒加载模式未命中时自动执行 Supplier 并回写 Session
Redis Key 格式排查账号 Session 数据时satoken:login:session:{userId}

5.4. Token-Session:终端维度的独立容器

Account-Session 解决了"账号级数据共享"的问题,但有时候我们需要存储的数据不是账号级的,而是终端级的——也就是说,同一个账号的手机端和电脑端,应该拥有各自独立的数据空间,互不干扰。

Token-Session 就是为这种场景设计的:每个 Token 拥有自己独立的 Session,两个 Token 即使属于同一个账号,它们的 Token-Session 也完全隔离,互不可见。

5.4.1. Account-Session 与 Token-Session 的边界

在写代码之前,我们先把两者的边界划清楚,这是实际开发中最容易混淆的地方:

问题Account-SessionToken-Session
同账号多设备,数据是否共享?✅ 共享,所有设备读写同一份❌ 隔离,每个设备独立
数据与 Token 是否绑定?❌ 不绑定,以用户 ID 为键✅ 绑定,以 Token 值为键
一个账号最多有几个?始终只有一个有几个活跃 Token 就有几个
适合存储什么?用户信息、权限列表、全局配置当前终端的临时操作状态、步骤记录

一个具体的业务场景可以帮助你直观区分:用户的"权限列表"应该存在 Account-Session 里(所有终端的权限是一样的),但"当前正在编辑中的草稿 ID"应该存在 Token-Session 里(手机端和电脑端各自编辑各自的草稿,互不干扰)。

5.4.2. 核心 API

Token-Session 的获取方式比 Account-Session 简单,只有两个 API:

1
2
3
4
5
6
// 获取当前请求的 Token 所对应的 Token-Session
// 默认情况下,只有已登录的请求才能调用(未登录会抛出 NotLoginException)
StpUtil.getTokenSession();

// 获取指定 Token 值对应的 Token-Session(可用于管理后台操作其他用户的终端)
StpUtil.getTokenSessionByToken("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");

获取到的同样是 SaSession 对象,存取数据的 API 与 Account-Session 完全一致。

5.4.3. 演示终端隔离特性

我们在 SessionDemoController 中追加两个方法来演示 Token-Session 的隔离特性:

📄 文件:src/main/java/com/example/authsatoken/controller/SessionDemoController.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
/**
* 向当前 Token 的 Token-Session 写入数据
* 每个 Token 写入的数据是隔离的,不会互相影响
*/
@PostMapping("/token/set")
public SaResult tokenSet(@RequestParam String deviceNote) {
// 获取当前 Token 的 Token-Session(与 Account-Session 同样简洁)
SaSession tokenSession = StpUtil.getTokenSession();

// 将设备备注写入当前 Token 的私有 Session
tokenSession.set("deviceNote", deviceNote);

return SaResult.ok("已向 Token-Session 写入:" + deviceNote);
}

/**
* 从当前 Token 的 Token-Session 读取数据
* 用 Token-A 写入的数据,Token-B 无法读取到
*/
@GetMapping("/token/get")
public SaResult tokenGet() {
SaSession tokenSession = StpUtil.getTokenSession();
String deviceNote = tokenSession.get("deviceNote", "(该终端尚未设置备注)");;
return SaResult.ok("当前终端备注:" + deviceNote);
}

现在用两个 Token(Token-A 和 Token-B,来自前面模拟的两次登录)分别测试:

用 Token-A 写入:

1
2
POST http://localhost:8081/session/token/set?deviceNote=这是手机端
(Header: satoken = Token-A 的值)

用 Token-B 读取:

1
2
GET http://localhost:8081/session/token/get
(Header: satoken = Token-B 的值)

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "当前终端备注:(该终端尚未设置备注)",
"data": null
}

Token-B 读取不到 Token-A 写入的数据,两个终端的 Session 完全隔离,这正是 Token-Session 的设计目标。再用 Token-A 读取:

1
2
GET http://localhost:8081/session/token/get
(Header: satoken = Token-A 的值)

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "当前终端备注:这是手机端",
"data": null
}

5.4.4. 用 Redis 确认 Token-Session 的 Key 结构

此时打开 Redis 客户端,执行 KEYS *,你会看到新增了 Token-Session 对应的 Key:

1
2
3
4
1) "satoken:login:token:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
2) "satoken:login:token:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
3) "satoken:login:session:10001"
4) "satoken:login:token-session:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"

注意第 4 条 Key 的格式:satoken:login:token-session:<token值>,它以 Token 的值本身作为标识符,所以每个 Token 的 Token-Session 是天然隔离的。我们只为 Token-A 写入了数据,所以只有 Token-A 的 token-session Key 被创建了,Token-B 的不存在。

Token-Session 默认只有在开发者主动调用 getTokenSession() 时才会创建,不会随登录自动生成,这与 Account-Session 的行为不同(Account-Session 在登录时自动创建)。

5.4.5. 未登录场景下使用 Token-Session

这是一个值得单独说明的特殊场景。默认配置下,调用 StpUtil.getTokenSession() 要求当前请求必须处于登录状态,未登录时会抛出 NotLoginException

但在某些业务场景下,你可能需要在用户正式登录之前就为当前终端挂载临时数据——例如记录用户的未登录状态下的购物车,或者多步骤表单的中间状态。Sa-Token 提供了两种解法:

解法一:修改全局配置(影响所有接口)

application.yml 中追加:

1
2
3
4
sa-token:
# 将此项改为 false,允许在未登录状态下调用 getTokenSession()
# 默认值为 true(即默认要求登录才能使用 Token-Session)
token-session-check-login: false

这个配置的影响范围是全局的,修改后整个应用的所有 getTokenSession() 调用都不再校验登录状态。如果你只有个别接口需要这种行为,不建议使用全局配置,而是使用解法二。

解法二:使用匿名 Token-Session(精细控制)

1
2
3
// 获取当前 Token 的匿名 Token-Session
// 在未登录时同样可以调用,框架不会抛出异常
StpUtil.getAnonTokenSession();

这里有一个需要注意的细节:如果前端发来的请求没有携带任何 Token,或者携带了一个已经失效的 Token,框架不会报错,而是会随机生成一个新的 Token 值来创建这个匿名 Token-Session。这个新生成的 Token 值可以通过 StpUtil.getTokenValue() 获取,你应该将它返回给前端保存,否则下次请求时这个匿名 Session 就无法被找回了。

5.4.6. 本节小结

本节完成了 Token-Session 的核心 API 学习,通过双 Token 隔离测试直观验证了终端隔离特性,并覆盖了未登录场景下的两种使用策略。

要点何时使用关键动作
StpUtil.getTokenSession()需要存储与当前终端绑定的数据时默认要求已登录,数据以 Token 为隔离键
StpUtil.getAnonTokenSession()未登录前需要临时记录终端状态时注意将新生成的 Token 返回给前端保存
Redis Key 格式排查 Token-Session 数据时satoken:login:token-session:{token值}

5.5. Custom-Session:突破账号边界,为任意业务实体挂载数据

Account-Session 和 Token-Session 都是围绕"用户"这个核心设计的——前者以账号 ID 为键,后者以 Token 为键。但在实际业务开发中,我们经常需要为非用户的业务实体挂载临时数据,比如:

  • 为商品 ID 10001 挂载一个缓存,存储该商品的实时库存快照
  • 为订单 ID ORDER-2025-88888 挂载状态机数据,记录订单当前处于哪个审批步骤
  • 为一个临时会议室 ID 挂载参会者列表

这些场景的共同特点是:数据的归属主体不是"用户",而是某个业务实体。如果强行用 Account-Session 来存储,会导致数据被混入用户的 Session 中,职责混乱;如果单独建一套 Redis 操作逻辑,又回到了手写轮子的老路。Custom-Session 正是为此而生的。

5.5.1. 设计思路

Custom-Session 的本质是:以一个你自定义的字符串作为 Session ID,让框架在 Redis 中为你创建和管理一个独立的 SaSession 对象。你不需要关心 Redis 连接、序列化、TTL 管理等细节,只需要给定一个唯一的字符串 key,剩下的交给 Sa-Token。

5.5.2. 核心 API

Custom-Session 通过独立的工具类 SaSessionCustomUtil 来操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import cn.dev33.satoken.session.SaSessionCustomUtil;

// 检查指定 key 的 Session 是否已存在于 Redis 中
SaSessionCustomUtil.isExists("goods-10001");

// 获取指定 key 的 Session,若不存在则自动创建并返回(最常用)
SaSession session = SaSessionCustomUtil.getSessionById("goods-10001");

// 获取指定 key 的 Session,第二个参数决定不存在时是否自动创建
// false:不存在则返回 null(适合只读场景,避免意外创建)
SaSession session = SaSessionCustomUtil.getSessionById("goods-10001", false);

// 主动删除指定 key 的 Session(清理不再需要的临时数据)
SaSessionCustomUtil.deleteSessionById("goods-10001");

注意这里的 key("goods-10001")就是你给这个业务实体的 Session 取的名字,框架会在 Redis 中以 satoken:custom:session:goods-10001 的格式存储它。

5.5.3. 完整演示:为商品挂载实时数据

我们用一个商品浏览量缓存的场景来演示 Custom-Session 的完整生命周期。

SessionDemoController 中追加以下方法:

📄 文件:src/main/java/com/example/authsatoken/controller/SessionDemoController.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 演示 Custom-Session:为商品挂载实时浏览量数据
* 场景:用户访问商品详情页时,记录浏览量(此接口无需登录)
*/
@PostMapping("/custom/goods/view")
public SaResult goodsView(@RequestParam Long goodsId) {
// 以商品 ID 拼接成唯一 key,格式统一为 "goods-{id}"
// 这个 key 就是 Custom-Session 的身份标识,整个应用内必须唯一
String sessionKey = "goods-" + goodsId;

// 获取该商品的 Session,不存在时自动创建
SaSession goodsSession = SaSessionCustomUtil.getSessionById(sessionKey);

// 取出当前浏览量,若还未记录过则默认为 0
int viewCount = goodsSession.getInt("viewCount", 0);

// 自增后写回
goodsSession.set("viewCount", viewCount + 1);

return SaResult.ok("商品 " + goodsId + " 当前浏览量:" + (viewCount + 1));
}

/**
* 查询指定商品的缓存数据(只读,不主动创建 Session)
*/
@GetMapping("/custom/goods/info")
public SaResult goodsInfo(@RequestParam Long goodsId) {
String sessionKey = "goods-" + goodsId;

// 传入 false:Session 不存在时返回 null,而不是自动创建
// 避免因为查询操作意外产生空的 Session 数据
SaSession goodsSession = SaSessionCustomUtil.getSessionById(sessionKey, false);

if (goodsSession == null) {
return SaResult.error("该商品尚无缓存数据,请先访问商品详情页");
}

int viewCount = goodsSession.getInt("viewCount", 0);
return SaResult.ok("查询成功").setData("商品 " + goodsId + " 浏览量=" + viewCount);
}

/**
* 手动清除指定商品的 Custom-Session(例如商品下架时清理缓存)
*/
@DeleteMapping("/custom/goods/clear")
public SaResult goodsClear(@RequestParam Long goodsId) {
String sessionKey = "goods-" + goodsId;

// 先检查是否存在,避免对空数据发起删除操作
if (!SaSessionCustomUtil.isExists(sessionKey)) {
return SaResult.error("该商品暂无缓存数据,无需清理");
}

SaSessionCustomUtil.deleteSessionById(sessionKey);
return SaResult.ok("商品 " + goodsId + " 的缓存已清除");
}

现在按顺序测试这三个接口,观察 Custom-Session 的完整生命周期。

测试一:触发浏览量记录

连续调用三次,模拟三次商品访问(Custom-Session 无需登录即可使用):

1
2
3
POST http://localhost:8081/session/custom/goods/view?goodsId=10001
POST http://localhost:8081/session/custom/goods/view?goodsId=10001
POST http://localhost:8081/session/custom/goods/view?goodsId=10001

第三次的预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "商品 10001 当前浏览量:3",
"data": null
}

测试二:只读查询

1
GET http://localhost:8081/session/custom/goods/info?goodsId=10001

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "查询成功",
"data": "商品 10001 浏览量=3"
}

查询一个从未被访问过的商品:

1
GET http://localhost:8081/session/custom/goods/info?goodsId=99999

预期响应:

1
2
3
4
5
{
"code": 500,
"msg": "该商品尚无缓存数据,请先访问商品详情页",
"data": null
}

getSessionById(key, false) 在 Session 不存在时返回 null 而不是自动创建,这正是"只读查询传 false"的价值所在——你不会因为一次查询操作,在 Redis 里留下一堆空 Session 垃圾数据。

测试三:清除缓存

1
DELETE http://localhost:8081/session/custom/goods/clear?goodsId=10001

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "商品 10001 的缓存已清除",
"data": null
}

此时打开 Redis 客户端执行 KEYS satoken:custom:*,预期输出为空,说明 Custom-Session 数据已从 Redis 中彻底删除。

5.5.4. 用 Redis 观察 Custom-Session 的 Key 格式

在执行清除操作之前,我们先看看 Custom-Session 在 Redis 中的实际存储形式。完成三次浏览量记录后,执行:

1
2
> KEYS satoken:custom:*
1) "satoken:custom:session:goods-10001"

和 Account-Session、Token-Session 不同,Custom-Session 的 Redis Key 前缀是 satoken:custom:session:,后面紧跟你传入的自定义 key 字符串。查看它的内容:

1
> GET "satoken:custom:session:goods-10001"

返回的 JSON 结构与前两种 Session 一致,都是 SaSession 对象,只是 type 字段变成了 Custom-SessiondataMap 里存着我们写入的 viewCount

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"@class": "cn.dev33.satoken.session.SaSession",
"id": "satoken:custom:session:goods-10001",
"type": "Custom-Session",
"loginType": null,
"loginId": null,
"createTime": 1772520001234,
"dataMap": {
"@class": "java.util.concurrent.ConcurrentHashMap",
"viewCount": 3
},
"terminalList": ["java.util.Vector", []]
}

注意 loginIdloginType 均为 null,这进一步说明 Custom-Session 与"用户"这个概念完全脱钩——它就是一个以你给定的 key 为标识的独立数据容器,和谁登录了系统毫无关联。


5.6. SaSession 的完整存取 API 速查

前面三节我们在演示中已经用到了 SaSession 的大部分常用方法。这里把 SaSession 对象上的全部存取 API 系统性地梳理一遍,方便后续查阅。

无论是 Account-Session、Token-Session 还是 Custom-Session,拿到的都是同一种对象——SaSession,存取数据的方式完全相同。

5.6.1. 写入与更新

1
2
3
4
5
6
7
// 基础写入:存入任意键值对,值为 Object 类型,可以是字符串、数字、POJO 等
session.set("name", "管理员小王");
session.set("age", 28);

// 条件写入:仅在该 key 原本不存在时才写入,已有值时静默跳过
// 适合"初始化默认值"的场景,避免意外覆盖已有数据
session.setDefaultValue("loginCount", 0);

setDefaultValue 的应用场景很典型:用户第一次登录时初始化计数器,后续登录时不希望被重置为 0,用这个方法就能安全地做到。

5.6.2. 读取与类型转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 基础取值,返回 Object 类型,需要自行强转
Object value = session.get("name");

// 带默认值的取值:key 不存在时返回指定默认值,而不是 null
// 强烈推荐在业务代码中优先使用带默认值的重载,可以省去大量 null 判断
Object value = session.get("name", "匿名用户");

// 懒加载取值:key 不存在时执行 Supplier,将结果写入 Session 后返回
// 适合"查缓存→未命中→查库→回写"的经典模式
Object value = session.get("userInfo", () -> userService.findById(10001));

// 类型安全的取值方法,内部会进行自动类型转换
session.getString("name"); // 转为 String 类型
session.getInt("age"); // 转为 int 类型
session.getLong("userId"); // 转为 long 类型
session.getDouble("score"); // 转为 double 类型
session.getFloat("rate"); // 转为 float 类型

// 将值反序列化为指定的 POJO 类型(使用 Jackson 反序列化)
// 适合从 Session 中取出存储的自定义对象
session.getModel("userInfo", UserInfo.class);

// 带默认值版本:若 key 不存在或反序列化结果为 null,返回指定默认值
session.getModel("userInfo", UserInfo.class, new UserInfo());

关于 getModel 有一个常见陷阱需要预先说明。

常见错误:直接存入对象后取出类型不匹配

1
2
3
4
5
6
7
8
9
// 存入时,Java 对象会被 Jackson 序列化成 JSON 存入 Redis
session.set("userInfo", new UserInfo(10001, "小王"));

// ❌ 错误:直接强转会抛出 ClassCastException
// 因为从 Redis 取回的是 LinkedHashMap(Jackson 默认反序列化结果),不是 UserInfo
UserInfo user = (UserInfo) session.get("userInfo");

// ✅ 正确:使用 getModel 指定目标类型,框架负责完成反序列化
UserInfo user = session.getModel("userInfo", UserInfo.class);

这个错误非常隐蔽,在开发阶段可能因为类型信息还在内存中而不报错,但一旦应用重启、数据从 Redis 重新加载,就会在运行时抛出 ClassCastException存入自定义对象时,取出时一律使用 getModel,这是一条不容破例的实践准则。

5.6.3. 删除与清空

1
2
3
4
5
6
7
8
9
10
11
// 删除单个 key
session.delete("name");

// 清空该 Session 下的所有 key(Session 对象本身仍然存在于 Redis 中)
session.clear();

// 查看该 Session 中存有哪些 key(返回 Set<String>)
Set<String> keys = session.keys();

// 判断某个 key 是否存在(返回 true 或 false)
boolean exists = session.has("name");

5.6.4. Session 自身的元信息与管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取该 Session 的 ID(就是 Redis 中存储它的 Key)
String id = session.getId();

// 获取该 Session 的创建时间(Unix 毫秒时间戳)
long createTime = session.getCreateTime();

// 获取该 Session 底层存储数据的原始 Map 对象
// 注意:直接修改这个 Map 里的值后,必须调用 session.update() 才能持久化到 Redis
// 否则修改只存在于内存中,下一次从 Redis 加载时会丢失(脏数据问题)
java.util.Map<String, Object> dataMap = session.getDataMap();
dataMap.put("name", "直接修改");
session.update(); // 必须手动调用,否则修改不生效

// 将当前内存中的 Session 数据强制同步到 Redis(用于直接操作 dataMap 之后)
session.update();

// 注销该 Session:从 Redis 中彻底删除这个 Session 对象
// 注意与 StpUtil.logout() 的区别:logout() 会级联处理 Token 等关联数据
// session.logout() 只是单纯删除这个 Session 对象本身
session.logout();

关于 session.update() 的使用时机,这里单独举例说明:

1
2
3
4
5
6
7
8
9
SaSession session = StpUtil.getSession();

// ✅ 通过 set() 方法修改,框架内部会自动同步到 Redis,无需手动 update()
session.set("count", 10);

// ⚠️ 直接操作 dataMap,框架感知不到你的修改,需要手动 update()
Map<String, Object> map = session.getDataMap();
map.put("count", 20);
session.update(); // 不调用这行,Redis 中仍然是 10

在绝大多数场景下,你只需要通过 set()delete() 操作 Session,不需要直接碰 dataMap。只有在需要批量操作或原子性更新多个 key 时,才考虑操作 dataMap + 手动 update()


5.7. 三种 Session 的对比与选型指南

学完三种 Session 之后,我们做一次完整的横向对比,帮助你在实际业务中快速做出正确选择。

对比维度Account-SessionToken-SessionCustom-Session
Redis Key 格式satoken:login:session:{userId}satoken:login:token-session:{token}satoken:custom:session:{自定义key}
获取方式StpUtil.getSession()StpUtil.getTokenSession()SaSessionCustomUtil.getSessionById(key)
归属维度账号(用户 ID)终端(Token 值)任意业务实体
同账号多设备共享同一个每个终端独立与账号无关
需要登录才能获取✅ 是✅ 是(默认)❌ 否
生命周期与账号登录状态同步与 Token 有效期同步开发者手动控制
适合存储用户信息、权限列表终端操作状态、草稿 ID商品/订单等业务实体的临时数据

在实际项目中,三种 Session 的选型可以归纳为以下三个问题:

问题一:这份数据属于"人"还是属于"物"?

如果答案是"人",继续问第二个问题;如果答案是"物"(商品、订单、会议室等),选 Custom-Session。

问题二:同一个人的多台设备,应该看到同一份数据还是各自独立的数据?

如果"应该共享"(比如权限列表),选 Account-Session;如果"应该隔离"(比如当前编辑的草稿),选 Token-Session。

问题三:这份数据在用户未登录时是否需要存在?

如果需要(比如匿名购物车),选 Custom-Session 或配合 getAnonTokenSession() 使用;如果不需要,Account-Session 和 Token-Session 都适用。


5.8. 避免与 HttpSession 混淆

这里有一个必须提前预警的混淆点,在实际项目中出错的频率相当高。

你在 Controller 方法参数中写 HttpSession session,和 StpUtil.getSession() 拿到的 SaSession完全是两种不同的东西,数据存储位置不同,读写互不影响。

一个最典型的错误示范:

1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/wrongDemo")
public SaResult wrongDemo(HttpSession httpSession) {
// 向 HttpSession 写入数据
// 这个数据存在 Servlet 容器(Tomcat)的内存中
httpSession.setAttribute("name", "小王");

// 从 SaSession 取值——永远取不到,因为两者根本不是同一个容器
Object name = StpUtil.getSession().get("name");
// name 的值是 null,不是"小王"
return SaResult.data(name);
}

这段代码在运行时不会报任何错误,但 name 的值始终是 null,因为数据压根没有存进 SaSession。这类 Bug 非常难排查,因为代码看起来完全正确。

两者的本质区别如下:

对比维度HttpSessionSaSession
存储位置Servlet 容器(Tomcat)内存Sa-Token 的持久化层(Redis)
会话标识JSESSIONID CookieSa-Token 的 Token 值
是否被框架接管❌ Sa-Token 不管它✅ 由 Sa-Token 完全管理
集群部署是否共享❌ 不共享(除非配置 Session 共享)✅ 天然共享(数据在 Redis 中)
推荐使用❌ 在使用 Sa-Token 时请完全放弃✅ 始终使用这个

结论只有一句话:引入 Sa-Token 后,在任何情况下都不要使用 HttpSession,它与 Sa-Token 的会话体系没有任何关联