登录注册番外篇(一) - 2026 年 Sa-Token 快速入门之基础登录
登录注册番外篇(一) - 2026 年 Sa-Token 快速入门之基础登录
Prorise第一章. 回头看:我们造了多少轮子
环境版本
| 组件 | 版本 |
|---|---|
| JDK | 17 |
| Spring Boot | 3.4.x |
| Sa-Token | 1.44.0 |
| Redis | 7.x |
| Maven | 3.9.x |
阶段式学习路径
本篇是番外篇系列的第一站。在基础篇中,我们花了六章的篇幅,从零手写了一套完整的认证内核——RSA 加密、JJWT 双令牌、Redis 黑名单、多设备管理。现在,我们暂停主线,换一个全新的视角:如果有一个框架,能用一行代码完成登录,我们还需要手写那么多东西吗?
若你对登录注册系列基础篇感兴趣,请跳转至:
本篇将带你认识 Sa-Token 框架,搭建全新的独立项目,并体验它的核心登录 API 与 Redis 集成能力。在正式认识 Sa-Token 之前,我们先做一件事——回头看看基础篇六章走下来,我们到底写了多少代码。这不是为了否定之前的努力,恰恰相反,正是因为我们亲手造过这些轮子,才能真正理解框架帮我们省掉了什么。
1.1. 基础篇的六大手写模块回顾
让我们快速盘点一下基础篇的产出。六章内容,我们在 auth 项目中构建了一个三模块的认证系统:
1 | auth/ |
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 | // 参数是用户 ID,可以是 long、int、String 等任意类型 |
没有 JwtUtil,没有 RsaKeyManager,没有 TokenRedisManager。一行代码,框架自动完成了 Token 生成、Session 创建、Cookie 写入(或响应头返回)等全部工作。
让我们看看 Sa-Token 的五大核心模块,对它的能力范围建立一个全局认知:
| 模块 | 解决的问题 | 本系列是否覆盖 |
|---|---|---|
| 登录认证 | 用户登录、注销、Token 管理、多设备登录 | ✅ 本篇开始 |
| 权限认证 | 角色校验、权限码校验、注解鉴权、路由拦截 | ✅ 后续篇章 |
| 单点登录(SSO) | 多系统共享登录态 | ❌ 不在本系列范围 |
| OAuth2.0 | 第三方授权登录 | ❌ 进阶篇单独讲解 |
| 微服务网关鉴权 | Gateway 层统一鉴权 | ❌ 不在本系列范围 |
Sa-Token 的设计哲学是"一个方法解决一个问题",API 命名直白到几乎不需要查文档。
2.2. 为什么选 Sa-Token 而不是 Spring Security
你可能会想:Java 安全领域的"正统"不是 Spring Security 吗?为什么我们先讲 Sa-Token?这个问题很好,我们从三个维度来回答。
学习曲线
Spring Security 的核心是一条 过滤器链由多个安全过滤器串联组成的请求处理链,每个请求都必须经过所有过滤器。要理解它,你需要先搞清楚 SecurityFilterChain、AuthenticationManager、AuthenticationProvider、UserDetailsService、SecurityContextHolder 这一整套概念,然后才能写出第一个自定义登录逻辑。
Sa-Token 的学习路径则完全不同。它没有过滤器链的概念,所有操作都通过一个静态工具类 StpUtil 完成。你不需要理解任何底层架构,打开 API 文档,找到你需要的方法,直接调用就行。
官方文档链接请跳转至:https://sa-token.cc/doc.html#/
老师,Spring Security 和 Sa-Token 上手难度差多少?
打个比方,Spring Security 像是一辆手动挡赛车,性能强悍但你得先学会换挡。Sa-Token 像是一辆自动挡家用车,上车就能开。
那 Sa-Token 是不是功能比较弱?
不是。它覆盖了登录认证、权限校验、多设备管理、踢人下线等绝大多数场景。只是在 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-Token | Spring 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,填写以下信息:
- Name:
auth-satoken - Group:
com.example - Artifact:
auth-satoken - Type:Maven
- Language:Java
- JDK:17
- Java:17
- Packaging:Jar
在依赖选择页面,勾选以下两项:
Spring WebSpring Data Redis
点击 Create 完成创建。操作成功后,IDEA 会自动打开新项目窗口,底部状态栏显示 Maven 依赖正在下载,等待进度条消失即可。
如果你更习惯手动创建,也可以访问 https://start.spring.io 生成项目骨架后导入 IDEA。
步骤 2:确认项目结构
创建完成后,项目的初始结构如下:
1 | auth-satoken/ |
步骤 3:引入 Sa-Token 和 Hutool 依赖
Spring Initializr 只生成了 spring-boot-starter-web 和 spring-boot-starter-data-redis,我们还需要手动追加 Sa-Token 相关依赖。打开 pom.xml,在 <dependencies> 节点中追加以下内容:
📄 文件:pom.xml(修改)
1 | <!-- Sa-Token 权限认证(SpringBoot3 专用 Starter) --> |
这里有几个关键点需要说明。
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 | server: |
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
sa-token-spring-boot3-starter | Spring Boot 3.x 项目引入 Sa-Token | 注意 boot3 与 boot 的版本区分 |
sa-token-redis-jackson | 需要将会话持久化到 Redis 时 | 引入后自动切换存储层,无需额外配置 |
is-concurrent + is-share 组合 | 项目初始化时确定登录策略 | 根据业务选择,可在登录时被参数覆盖 |
| 配置项 | 推荐值 | 控制内容 |
|---|---|---|
token-name | satoken | Cookie 名 / Header 名 / URL 参数名 |
timeout | 2592000(30 天) | Token 最大存活时间 |
is-concurrent | true(多端)/ 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,整个过程对业务代码完全透明。
Stp 是 t(token)+ p(permission)缩写演化而来的历史命名,你不需要深究它的含义,记住"Sa-Token 的静态操作入口"这个定位就够了。
4.2. SaResult:Sa-Token 内置的统一响应类
在写第一个接口之前,还有一个类需要先了解——SaResult,它是 Sa-Token 内置的统一 HTTP 响应封装类,结构和我们在基础篇手写的 Result 几乎完全一致。
SaResult 有三个核心字段:
| 字段 | 类型 | 含义 |
|---|---|---|
code | int | 状态码,默认 200 表示成功,500 表示失败 |
msg | String | 提示信息 |
data | Object | 响应数据,可以是任意类型 |
常用的静态工厂方法如下:
1 | SaResult.ok("操作成功"); // code=200, msg="操作成功", data=null |
值得注意的是,setCode()、setMsg()、setData() 都返回 SaResult 本身,支持链式调用。在实际项目中,你完全可以继续使用自己的 Result 类,SaResult 不是强制的——本篇为了减少额外的脚手架代码,直接使用它。
4.3. 登录、注销与状态查询
理解了 StpUtil 和 SaResult 的设计之后,我们来写第一个功能完整的控制器。
首先在 com.example.authsatoken 包下新建 controller 子包,然后创建 LoginController.java。
📄 文件:src/main/java/com/example/authsatoken/controller/LoginController.java(新建)
1 | package com.example.authsatoken.controller; |
这段代码的核心是 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 | /** |
StpUtil.getTokenInfo() 返回一个 SaTokenInfo 对象,包含了当前会话的完整快照,以下是各字段的含义:
| 字段 | 类型 | 含义 |
|---|---|---|
tokenName | String | Token 名称,即 token-name 配置值 |
tokenValue | String | 当前 Token 的具体值 |
isLogin | boolean | 是否处于登录状态 |
loginId | Object | 登录时传入的用户 ID(以 String 形式存储) |
loginType | String | 登录类型,默认 login |
tokenTimeout | long | Token 剩余有效期(秒),-1 表示永不过期,-2 表示已过期 |
sessionTimeout | long | Session 剩余有效期(秒) |
loginDeviceType | String | 登录设备类型,未指定时默认 DEF |
StpUtil.getLoginId() 返回当前登录用户的 ID。有一个细节需要特别留意:无论登录时传入的是 long 还是 int,getLoginId() 都以 Object 类型返回,实际存储的是 String "10001" 而非 Long 10001。如果你需要精确类型,请使用:
1 | // 返回 String 类型的 ID,是最常用的安全写法 |
下面是 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 中运行 AuthSatokenApplication 的 main 方法。观察控制台输出,如果看到以下字符画,说明 Sa-Token 已成功加载:
1 | ____ ____ ______ ____ __ __ ____ _ __ |
同时确认控制台没有红色报错信息,并且出现了 Started AuthSatokenApplication in x.xx seconds 的提示。
步骤 3:测试登录接口
使用 Postman(或 curl)发送 POST 请求:
1 | POST http://localhost:8081/auth/login?username=admin&password=123456 |
预期响应:
1 | { |
data 字段返回的就是 Token 值,每次登录生成的值都不同(因为是 UUID)。请将这个 Token 值复制备用,后续带认证的请求都需要它。
步骤 4:测试登录状态查询
在 Postman 的请求 Header 中添加:
1 | satoken: 1db92b69-74ce-4c5f-a838-13c0f414d047 (替换为你实际的 Token) |
然后访问:
1 | GET http://localhost:8081/auth/isLogin |
预期响应:
1 | { |
步骤 5:测试获取 Token 完整信息
同样携带 Token Header,访问:
1 | GET http://localhost:8081/auth/tokenInfo |
预期响应:
1 | { |
注意 loginId 字段的值是字符串 "10001" 而非数字 10001,这印证了前面说的"Sa-Token 内部统一以 String 存储用户 ID"。loginDeviceType 显示为 DEF,这是因为我们调用 StpUtil.login(10001) 时没有指定设备类型,框架使用了默认值。后续章节中我们会学习如何指定设备类型。
步骤 6:测试注销接口
携带 Token Header,访问:
1 | POST http://localhost:8081/auth/logout |
预期响应:
1 | { |
注销后,再次访问 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 观察实验做好了铺垫。
核心汇总表
| 类 | 职责 | 关键点 |
|---|---|---|
StpUtil | Sa-Token 全部操作的静态入口 | 通过 ThreadLocal 感知当前请求,无需注入 |
SaResult | 统一 HTTP 响应封装 | code/msg/data,支持链式 setXxx() |
SaTokenInfo | Token 会话的完整快照 | 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 | 1. 请求头 Header(key = token-name,即 satoken) |
所以这两种模式分别对应什么场景?
| 模式 | 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-Session | Token 维度(每个 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 | // 获取当前登录账号的 Account-Session(必须在已登录状态下调用) |
这些方法返回的都是 SaSession 对象,拿到之后你就可以在上面自由地存取任意键值对。
5.3.2. 在 Account-Session 上存取数据
我们来写一个具体的场景:用户登录成功后,将用户对象缓存到 Account-Session 中,后续任何接口都可以直接从 Session 中取出,而不需要每次都查数据库。
首先在 controller 包下新建 SessionDemoController.java,专门用来演示三种 Session 的操作:
📄 文件:src/main/java/com/example/authsatoken/controller/SessionDemoController.java(新建)
1 | package com.example.authsatoken.controller; |
步骤 1: 确保已登录(参考第四章,调用 /auth/login 获取 Token),然后携带 Token 请求:
1 | POST http://localhost:8081/session/account/set |
预期响应:
1 | { |
步骤 2: 随后请求读取接口:
1 | GET http://localhost:8081/session/account/get |
预期响应:
1 | { |
email 键我们从未设置过,所以 session.get("email", "暂未设置") 触发了默认值逻辑,返回了 "暂未设置" 而不是 null。这是一个非常实用的防空指针技巧,建议在实际项目中优先使用带默认值的取值方法。
5.3.3. 用 Redis 验证"账号共享"的本质
前面我们说 Account-Session 是账号维度的——整个账号只有一个 Session,多个 Token 共享它。现在我们用 Redis 来亲眼验证这一点。
重新登录,这次我们登录两次,模拟同一账号的两台设备:
1 | # 第一次登录(模拟手机端) |
此时打开 Redis 客户端执行 KEYS *,你会看到:
1 | 1) "satoken:login:token:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" |
三个 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 | { |
现在用 Token-A 携带请求调用 /session/account/set 写入昵称,再换用 Token-B 携带请求调用 /session/account/get 读取,结果是完全一致的。这就是 Account-Session 账号级共享的直观证明。
5.3.4. 懒加载取值:get 方法的第三种重载
SaSession 的 get 方法有一个非常实用但容易被忽略的重载——接受一个 Supplier 函数作为参数:
1 | // 若 Session 中已有 "userInfo" 这个 key,直接返回缓存值 |
这个写法实现了一个经典的缓存模式:先查缓存,未命中再查数据库,查到后自动写入缓存。在实际项目中,你可以用它替代以下冗长的模板代码:
1 | // ❌ 没有懒加载时需要这样写 |
两者行为完全一致,后者的代码量减少了三分之二,可读性也更高。
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-Session | Token-Session |
|---|---|---|
| 同账号多设备,数据是否共享? | ✅ 共享,所有设备读写同一份 | ❌ 隔离,每个设备独立 |
| 数据与 Token 是否绑定? | ❌ 不绑定,以用户 ID 为键 | ✅ 绑定,以 Token 值为键 |
| 一个账号最多有几个? | 始终只有一个 | 有几个活跃 Token 就有几个 |
| 适合存储什么? | 用户信息、权限列表、全局配置 | 当前终端的临时操作状态、步骤记录 |
一个具体的业务场景可以帮助你直观区分:用户的"权限列表"应该存在 Account-Session 里(所有终端的权限是一样的),但"当前正在编辑中的草稿 ID"应该存在 Token-Session 里(手机端和电脑端各自编辑各自的草稿,互不干扰)。
5.4.2. 核心 API
Token-Session 的获取方式比 Account-Session 简单,只有两个 API:
1 | // 获取当前请求的 Token 所对应的 Token-Session |
获取到的同样是 SaSession 对象,存取数据的 API 与 Account-Session 完全一致。
5.4.3. 演示终端隔离特性
我们在 SessionDemoController 中追加两个方法来演示 Token-Session 的隔离特性:
📄 文件:src/main/java/com/example/authsatoken/controller/SessionDemoController.java(修改,追加方法)
1 | /** |
现在用两个 Token(Token-A 和 Token-B,来自前面模拟的两次登录)分别测试:
用 Token-A 写入:
1 | POST http://localhost:8081/session/token/set?deviceNote=这是手机端 |
用 Token-B 读取:
1 | GET http://localhost:8081/session/token/get |
预期响应:
1 | { |
Token-B 读取不到 Token-A 写入的数据,两个终端的 Session 完全隔离,这正是 Token-Session 的设计目标。再用 Token-A 读取:
1 | GET http://localhost:8081/session/token/get |
预期响应:
1 | { |
5.4.4. 用 Redis 确认 Token-Session 的 Key 结构
此时打开 Redis 客户端,执行 KEYS *,你会看到新增了 Token-Session 对应的 Key:
1 | 1) "satoken:login:token: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 | sa-token: |
这个配置的影响范围是全局的,修改后整个应用的所有 getTokenSession() 调用都不再校验登录状态。如果你只有个别接口需要这种行为,不建议使用全局配置,而是使用解法二。
解法二:使用匿名 Token-Session(精细控制)
1 | // 获取当前 Token 的匿名 Token-Session |
这里有一个需要注意的细节:如果前端发来的请求没有携带任何 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 | import cn.dev33.satoken.session.SaSessionCustomUtil; |
注意这里的 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 | /** |
现在按顺序测试这三个接口,观察 Custom-Session 的完整生命周期。
测试一:触发浏览量记录
连续调用三次,模拟三次商品访问(Custom-Session 无需登录即可使用):
1 | POST http://localhost:8081/session/custom/goods/view?goodsId=10001 |
第三次的预期响应:
1 | { |
测试二:只读查询
1 | GET http://localhost:8081/session/custom/goods/info?goodsId=10001 |
预期响应:
1 | { |
查询一个从未被访问过的商品:
1 | GET http://localhost:8081/session/custom/goods/info?goodsId=99999 |
预期响应:
1 | { |
getSessionById(key, false) 在 Session 不存在时返回 null 而不是自动创建,这正是"只读查询传 false"的价值所在——你不会因为一次查询操作,在 Redis 里留下一堆空 Session 垃圾数据。
测试三:清除缓存
1 | DELETE http://localhost:8081/session/custom/goods/clear?goodsId=10001 |
预期响应:
1 | { |
此时打开 Redis 客户端执行 KEYS satoken:custom:*,预期输出为空,说明 Custom-Session 数据已从 Redis 中彻底删除。
5.5.4. 用 Redis 观察 Custom-Session 的 Key 格式
在执行清除操作之前,我们先看看 Custom-Session 在 Redis 中的实际存储形式。完成三次浏览量记录后,执行:
1 | > KEYS satoken:custom:* |
和 Account-Session、Token-Session 不同,Custom-Session 的 Redis Key 前缀是 satoken:custom:session:,后面紧跟你传入的自定义 key 字符串。查看它的内容:
1 | > GET "satoken:custom:session:goods-10001" |
返回的 JSON 结构与前两种 Session 一致,都是 SaSession 对象,只是 type 字段变成了 Custom-Session,dataMap 里存着我们写入的 viewCount:
1 | { |
注意 loginId 和 loginType 均为 null,这进一步说明 Custom-Session 与"用户"这个概念完全脱钩——它就是一个以你给定的 key 为标识的独立数据容器,和谁登录了系统毫无关联。
5.6. SaSession 的完整存取 API 速查
前面三节我们在演示中已经用到了 SaSession 的大部分常用方法。这里把 SaSession 对象上的全部存取 API 系统性地梳理一遍,方便后续查阅。
无论是 Account-Session、Token-Session 还是 Custom-Session,拿到的都是同一种对象——SaSession,存取数据的方式完全相同。
5.6.1. 写入与更新
1 | // 基础写入:存入任意键值对,值为 Object 类型,可以是字符串、数字、POJO 等 |
setDefaultValue 的应用场景很典型:用户第一次登录时初始化计数器,后续登录时不希望被重置为 0,用这个方法就能安全地做到。
5.6.2. 读取与类型转换
1 | // 基础取值,返回 Object 类型,需要自行强转 |
关于 getModel 有一个常见陷阱需要预先说明。
常见错误:直接存入对象后取出类型不匹配
1 | // 存入时,Java 对象会被 Jackson 序列化成 JSON 存入 Redis |
这个错误非常隐蔽,在开发阶段可能因为类型信息还在内存中而不报错,但一旦应用重启、数据从 Redis 重新加载,就会在运行时抛出 ClassCastException。存入自定义对象时,取出时一律使用 getModel,这是一条不容破例的实践准则。
5.6.3. 删除与清空
1 | // 删除单个 key |
5.6.4. Session 自身的元信息与管理
1 | // 获取该 Session 的 ID(就是 Redis 中存储它的 Key) |
关于 session.update() 的使用时机,这里单独举例说明:
1 | SaSession session = StpUtil.getSession(); |
在绝大多数场景下,你只需要通过 set() 和 delete() 操作 Session,不需要直接碰 dataMap。只有在需要批量操作或原子性更新多个 key 时,才考虑操作 dataMap + 手动 update()。
5.7. 三种 Session 的对比与选型指南
学完三种 Session 之后,我们做一次完整的横向对比,帮助你在实际业务中快速做出正确选择。
| 对比维度 | Account-Session | Token-Session | Custom-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 |
|
这段代码在运行时不会报任何错误,但 name 的值始终是 null,因为数据压根没有存进 SaSession。这类 Bug 非常难排查,因为代码看起来完全正确。
两者的本质区别如下:
| 对比维度 | HttpSession | SaSession |
|---|---|---|
| 存储位置 | Servlet 容器(Tomcat)内存 | Sa-Token 的持久化层(Redis) |
| 会话标识 | JSESSIONID Cookie | Sa-Token 的 Token 值 |
| 是否被框架接管 | ❌ Sa-Token 不管它 | ✅ 由 Sa-Token 完全管理 |
| 集群部署是否共享 | ❌ 不共享(除非配置 Session 共享) | ✅ 天然共享(数据在 Redis 中) |
| 推荐使用 | ❌ 在使用 Sa-Token 时请完全放弃 | ✅ 始终使用这个 |
结论只有一句话:引入 Sa-Token 后,在任何情况下都不要使用 HttpSession,它与 Sa-Token 的会话体系没有任何关联。






