番外篇三:权限认证完全指南
第一章. 权限数据源:StpInterface 的设计哲学
阶段式学习路径
在番外篇(一)和(二)中,我们完成了登录认证的全部功能——登录、注销、踢人、多端管理。但"登录"只是认证体系的第一道门。真实业务中,不同用户能做的事情是不同的:管理员可以删除用户,普通用户只能查看自己的信息;运营人员可以编辑文章,访客只能阅读。
这就是"权限认证"——登录解决的是"你是谁",权限解决的是"你能做什么"。
回忆一下基础篇——我们完全没有涉及权限认证。基础篇的六章全部聚焦在"登录"这一件事上,权限管理被留到了进阶篇。而在 Sa-Token 中,权限认证是框架的核心能力之一,并且它的设计思路非常巧妙:框架本身不关心你的权限数据存在哪里,它只要求你实现一个接口,告诉它"某个用户有哪些权限"和"某个用户有哪些角色"。
这个接口就是 StpInterface。
1.1. 为什么需要 StpInterface:三个设计考量
在开始写代码之前,我们先理解一个问题:为什么 Sa-Token 不直接提供"查询数据库获取权限"的功能,而是要求开发者实现一个接口?
这背后有三个深层的设计考量。
考量一:框架与业务完全解耦
不同项目的权限数据来源千差万别:
- 有的项目权限数据存在 MySQL 的
sys_user、sys_role、sys_permission 三张表中 - 有的项目权限数据存在 MongoDB 的文档中
- 有的项目权限数据通过远程 RPC 调用其他服务获取
- 有的项目权限数据直接硬编码在配置文件中(小型项目)
如果 Sa-Token 内置了"查询 MySQL 获取权限"的逻辑,那么使用 MongoDB 的项目就无法使用这个框架。通过 StpInterface 接口,Sa-Token 把"权限数据从哪来"的决策权完全交给开发者——你只需要告诉框架"这个用户有哪些权限",框架不关心你是怎么查到的。
考量二:缓存策略自主可控
权限数据的查询频率非常高——每次请求都可能触发权限校验。如果每次都查数据库,性能会成为瓶颈。但不同项目对缓存的需求不同:
- 有的项目权限数据几乎不变,可以用本地缓存(如 Caffeine)
- 有的项目权限数据频繁变化,需要用分布式缓存(如 Redis)
- 有的项目权限数据实时性要求极高,不能用缓存
Sa-Token 不强制你使用某种缓存方案。你可以在 StpInterfaceImpl 的实现中自由选择:直接查数据库、加本地缓存、加 Redis 缓存、甚至不缓存。框架只认接口返回的 List<String>,不管这个列表是从哪来的。
考量三:多租户与多业务线支持
StpInterface 的两个方法都有一个 loginType 参数:
1 2
| List<String> getPermissionList(Object loginId, String loginType); List<String> getRoleList(Object loginId, String loginType);
|
这个参数让你可以为不同的业务线实现不同的权限逻辑。比如一个电商系统,可能同时存在两套账号体系:
- 用户端(
loginType = "user"):普通用户登录,权限来自 user_permission 表 - 商家端(
loginType = "merchant"):商家登录,权限来自 merchant_permission 表
通过 loginType 参数,你可以在同一个 StpInterfaceImpl 中根据不同的业务线返回不同的权限数据。这在多租户 SaaS 系统中非常有用。
StpInterface 的设计哲学是"约定优于配置"——框架只约定接口规范,不配置具体实现。
1.2. Sa-Token 的权限模型:字符串即权限
在实现 StpInterface 之前,我们需要理解 Sa-Token 的权限模型。它非常简单:权限用字符串表示,角色也用字符串表示。
框架不强制你使用 RBAC 模型、不要求你建特定的数据库表、不限制你的权限编码格式。它只需要你回答两个问题:
- 给定一个用户 ID,这个用户有哪些权限码?(返回
List<String>) - 给定一个用户 ID,这个用户有哪些角色标识?(返回
List<String>)
权限码和角色标识的格式完全由你决定。业界最常见的格式是 资源:操作,比如:
1 2 3 4 5 6
| user:add 用户新增权限 user:delete 用户删除权限 user:update 用户修改权限 user:view 用户查看权限 article:publish 文章发布权限 order:cancel 订单取消权限
|
角色标识通常用简单的单词或缩写:
1 2 3
| admin 管理员角色 editor 编辑角色 viewer 访客角色
|
Sa-Token 在鉴权时会调用你实现的 StpInterface,拿到权限列表和角色列表后,自行判断是否放行。整个过程对你透明——你只需要提供数据,框架负责校验逻辑。
1.3. 实现 StpInterface:从硬编码到数据库
让我们在项目中创建 StpInterface 的实现类。为了聚焦 Sa-Token 本身的鉴权机制,我们先用硬编码模拟权限数据。这样做有两个好处:
- 不需要建数据库表,可以快速验证鉴权功能
- 后续接入数据库时,只需要替换
StpInterfaceImpl 的查询逻辑,其余代码完全不用动
📄 文件:src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java(新建)
在 com.example.authsatoken 包下新建 auth 目录,然后创建 StpInterfaceImpl.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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
| package com.example.authsatoken.auth;
import cn.dev33.satoken.stp.StpInterface; import org.springframework.stereotype.Component;
import java.util.ArrayList; import java.util.List; import java.util.Map;
@Component public class StpInterfaceImpl implements StpInterface {
private static final Map<String, List<String>> ROLE_PERMISSION_MAP = Map.of( "admin", List.of("user:add", "user:delete", "user:update", "user:view"), "editor", List.of("article:add", "article:delete", "article:update", "article:view"), "user", List.of("user:view", "article:view") );
private static final Map<String, List<String>> USER_ROLE_MAP = Map.of( "10001", List.of("admin"), "10002", List.of("editor"), "10003", List.of("user") );
@Override public List<String> getPermissionList(Object loginId, String loginType) { List<String> permissions = new ArrayList<>();
List<String> roles = getRoleList(loginId, loginType); for (String role : roles) { List<String> perms = ROLE_PERMISSION_MAP.get(role); if (perms != null) { permissions.addAll(perms); } }
return permissions; }
@Override public List<String> getRoleList(Object loginId, String loginType) { List<String> roles = USER_ROLE_MAP.get(String.valueOf(loginId)); return roles != null ? roles : List.of(); } }
|
关键设计说明
@Component 注解是必须的。Sa-Token 通过 Spring 的依赖注入机制自动发现 StpInterface 的实现类。只要你的实现类被 Spring 扫描到,框架就会自动使用它。不需要任何额外的配置或注册。
loginId 参数是 Object 类型。因为 Sa-Token 的 login() 方法接受任意类型的 ID(可以是 Long、String、Integer),所以这里用 Object 接收。在实际使用时,你需要根据自己的业务类型进行转换——如果你的用户 ID 是 Long 类型,可以用 ((Long) loginId).toString() 转换。
权限码的格式是自定义的。我们使用了 资源:操作 的格式(如 user:add、user:view),这是业界最常见的权限编码规范。Sa-Token 还支持通配符匹配——如果你给某个角色分配了 user:* 权限,它会自动匹配所有以 user: 开头的权限码(我们会在第五章详细讲解)。
方法在每次鉴权时调用,而不是在启动时。这意味着如果你修改了数据库中的权限数据,下次鉴权时就会生效,不需要重启应用。当然,如果你的权限数据变化不频繁,强烈建议加一层缓存来提升性能——每次请求都查数据库会成为性能瓶颈。
StpInterface 的实现类在整个项目中只能有一个。如果你注册了多个实现,Spring 会因为依赖冲突而报错。
1.4. 调整登录逻辑:让不同用户对应不同角色
现在我们有了权限数据源,但还需要调整登录逻辑,让不同用户名对应不同的用户 ID,这样才能测试不同角色的权限差异。
📄 文件:src/main/java/com/example/authsatoken/controller/LoginController.java(修改)
将 login 方法中的用户 ID 逻辑调整为根据用户名动态分配:
好的,我继续完整输出所有内容,不再中断。
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
| @PostMapping("/login") public SaResult login(@RequestParam String username, @RequestParam String password, @RequestParam(required = false, defaultValue = "PC") String device) { if (!"123456".equals(password)) { return SaResult.error("密码错误"); }
long userId; switch (username) { case "admin": userId = 10001L; break; case "editor": userId = 10002L; break; case "user": userId = 10003L; break; default: return SaResult.error("用户名不存在"); }
StpUtil.login(userId, new cn.dev33.satoken.stp.SaLoginModel() .setDevice(device) .setMaxLoginCount(3) );
return SaResult.ok("登录成功").setData(StpUtil.getTokenValue()); }
|
这样,admin 登录后的 loginId 是 10001(对应 admin 角色),editor 登录后的 loginId 是 10002(对应 editor 角色),user 登录后的 loginId 是 10003(对应 user 角色)。
1.5. 从硬编码到数据库:迁移路径
虽然我们现在用硬编码 Map 模拟权限数据,但实际项目中,权限数据必然存储在数据库中。让我们提前规划一下数据库表结构和迁移路径。
典型的 RBAC 数据库表结构
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
| CREATE TABLE sys_user ( id BIGINT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, ... );
CREATE TABLE sys_role ( id BIGINT PRIMARY KEY, role_code VARCHAR(50) NOT NULL, role_name VARCHAR(50) NOT NULL, ... );
CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY, permission_code VARCHAR(100) NOT NULL, permission_name VARCHAR(100) NOT NULL, ... );
CREATE TABLE sys_user_role ( user_id BIGINT, role_id BIGINT, PRIMARY KEY (user_id, role_id) );
CREATE TABLE sys_role_permission ( role_id BIGINT, permission_id BIGINT, PRIMARY KEY (role_id, permission_id) );
|
迁移后的 StpInterfaceImpl 实现
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
| @Component public class StpInterfaceImpl implements StpInterface {
@Autowired private UserRoleMapper userRoleMapper;
@Autowired private RolePermissionMapper rolePermissionMapper;
@Override public List<String> getPermissionList(Object loginId, String loginType) { List<Long> roleIds = userRoleMapper.selectRoleIdsByUserId((Long) loginId);
if (roleIds.isEmpty()) { return List.of(); } return rolePermissionMapper.selectPermissionCodesByRoleIds(roleIds); }
@Override public List<String> getRoleList(Object loginId, String loginType) { return userRoleMapper.selectRoleCodesByUserId((Long) loginId); } }
|
从硬编码迁移到数据库,只需要替换 StpInterfaceImpl 的实现,其余代码(Controller、注解、路由配置)完全不用动。这就是 StpInterface 接口解耦的价值。
1.6. 本节小结
本章理解了 StpInterface 的三个设计考量(框架与业务解耦、缓存策略自主、多业务线支持),掌握了 Sa-Token 的权限模型(字符串即权限),实现了基于硬编码 Map 的权限数据源,并规划了从硬编码到数据库的迁移路径。
| 要点 | 何时使用 | 关键动作 |
|---|
StpInterface 接口 | 项目需要权限认证时 | 实现 getPermissionList 和 getRoleList |
资源:操作 权限编码 | 设计权限体系时 | 使用 user:add 格式,支持 * 通配符 |
@Component 自动注册 | 创建实现类时 | 加注解即可,无需额外配置 |
第二章. 注解鉴权:在方法上声明权限要求
有了权限数据源,接下来就是"在哪里校验权限"。Sa-Token 提供了两种鉴权方式:注解鉴权和路由拦截鉴权。本章先讲注解鉴权——它更直观,也更适合细粒度的接口级控制。
注解鉴权的思路很简单:在 Controller 的方法上加一个注解,声明"访问这个接口需要什么权限"。Sa-Token 在请求到达方法之前自动校验,不满足条件就抛出异常,满足条件才放行。
但注解鉴权有一个前提:必须注册 Sa-Token 的拦截器,否则注解不会生效。这是很多初学者踩的第一个坑——加了注解却发现没有任何拦截效果。
2.1. 注册拦截器:让注解生效
Sa-Token 的注解鉴权依赖 SaInterceptor 拦截器。我们需要创建一个 Spring MVC 配置类来注册它。
📄 文件:src/main/java/com/example/authsatoken/config/SaTokenConfig.java(新建)
在 com.example.authsatoken 包下新建 config 目录,然后创建 SaTokenConfig.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| package com.example.authsatoken.config;
import cn.dev33.satoken.interceptor.SaInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration public class SaTokenConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor()) .addPathPatterns("/**"); } }
|
这里的 new SaInterceptor() 不传参数,表示只开启注解鉴权功能——拦截器会扫描 Controller 方法上的 Sa-Token 注解并执行校验,但不会对没有注解的方法做任何拦截。这是最基础的用法,第三章我们会在这个拦截器中加入路由级别的鉴权逻辑。
如果不注册 SaInterceptor,所有 Sa-Token 鉴权注解都不会生效,请求会直接到达 Controller 方法。
2.2. 四种鉴权注解
Sa-Token 提供了四种核心鉴权注解,覆盖了从"是否登录"到"是否有某个权限"的所有场景。让我们创建一个专门的测试控制器来体验它们。
📄 文件:src/main/java/com/example/authsatoken/controller/PermissionTestController.java(新建)
在 controller 包下创建 PermissionTestController.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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
| package com.example.authsatoken.controller;
import cn.dev33.satoken.annotation.*; import cn.dev33.satoken.util.SaResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@RestController @RequestMapping("/test") public class PermissionTestController {
@SaCheckLogin @GetMapping("/loginRequired") public SaResult loginRequired() { return SaResult.ok("你已通过登录校验"); }
@SaCheckRole("admin") @GetMapping("/adminOnly") public SaResult adminOnly() { return SaResult.ok("你拥有 admin 角色"); }
@SaCheckRole(value = {"admin", "editor"}, mode = SaMode.OR) @GetMapping("/adminOrEditor") public SaResult adminOrEditor() { return SaResult.ok("你拥有 admin 或 editor 角色"); }
@SaCheckRole({"admin", "editor"}) @GetMapping("/adminAndEditor") public SaResult adminAndEditor() { return SaResult.ok("你同时拥有 admin 和 editor 角色"); }
@SaCheckPermission("user:add") @GetMapping("/canAddUser") public SaResult canAddUser() { return SaResult.ok("你拥有用户新增权限"); }
@SaCheckPermission({"user:add", "user:delete"}) @GetMapping("/canManageUser") public SaResult canManageUser() { return SaResult.ok("你同时拥有用户新增和删除权限"); }
@SaCheckPermission(value = {"user:add", "user:delete"}, mode = SaMode.OR) @GetMapping("/canAddOrDeleteUser") public SaResult canAddOrDeleteUser() { return SaResult.ok("你拥有用户新增或删除权限"); }
@SaCheckPermission("article:add") @GetMapping("/canAddArticle") public SaResult canAddArticle() { return SaResult.ok("你拥有文章新增权限"); }
@SaCheckOr( role = @SaCheckRole("admin"), permission = @SaCheckPermission("user:delete") ) @GetMapping("/adminOrCanDeleteUser") public SaResult adminOrCanDeleteUser() { return SaResult.ok("你是管理员,或者拥有用户删除权限"); }
@SaCheckOr( role = @SaCheckRole("admin"), permission = @SaCheckPermission(value = {"editor", "article:delete"}, mode = SaMode.AND) ) @GetMapping("/complexCheck") public SaResult complexCheck() { return SaResult.ok("你通过了复杂组合校验"); } }
|
逐个解释这四种注解:
@SaCheckLogin 是最基础的——只校验"是否已登录",不关心角色和权限。适合那些所有登录用户都能访问的接口,比如"获取个人信息"、“修改密码”。
@SaCheckRole("admin") 校验当前用户是否拥有指定角色。Sa-Token 会调用我们在第一章实现的 StpInterfaceImpl.getRoleList() 方法获取角色列表,然后判断列表中是否包含 "admin"。如果不包含,抛出 NotRoleException。
当注解中传入多个角色时,默认是 AND 模式——必须同时拥有所有角色。如果想改为 OR 模式(满足任一即可),需要显式指定 mode = SaMode.OR。
@SaCheckPermission("user:add") 校验当前用户是否拥有指定权限码。逻辑和角色校验类似,调用的是 getPermissionList() 方法。多权限的 AND/OR 模式同理。
@SaCheckOr 是一个组合注解,它允许你把多种校验条件用 OR 关系组合在一起。上面的例子表示"拥有 admin 角色"或"拥有 user:delete 权限",满足其一即可通过。这在实际业务中很常见——比如"管理员可以删除任何文章,普通用户只能删除自己的文章"。
多个注解叠加在同一个方法上时,默认是 AND 关系——所有注解都必须通过。想要 OR 关系请使用 @SaCheckOr。
2.3. 补充异常处理:权限不足与角色不足
在测试注解鉴权之前,我们需要先补充全局异常处理器,捕获权限校验失败时抛出的异常。
📄 文件:src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(修改)
在番外篇(二)中创建的 GlobalExceptionHandler 中,追加两个异常处理方法:
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
| package com.example.authsatoken.exception;
import cn.dev33.satoken.exception.NotLoginException; import cn.dev33.satoken.exception.NotPermissionException; import cn.dev33.satoken.exception.NotRoleException; import cn.dev33.satoken.util.SaResult; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(NotLoginException.class) public SaResult handleNotLoginException(NotLoginException e) { String message = switch (e.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); }
@ExceptionHandler(NotPermissionException.class) public SaResult handleNotPermissionException(NotPermissionException e) { return SaResult.error("缺少权限:" + e.getPermission()).setCode(403); }
@ExceptionHandler(NotRoleException.class) public SaResult handleNotRoleException(NotRoleException e) { return SaResult.error("缺少角色:" + e.getRole()).setCode(403); } }
|
NotPermissionException 的 getPermission() 方法返回校验失败的权限码,NotRoleException 的 getRole() 方法返回校验失败的角色标识。状态码使用 403(Forbidden,禁止访问),区别于 401(Unauthorized,未认证)。
这两个状态码的语义差异很重要:401 表示"你还没有证明你是谁"(未登录),403 表示"我知道你是谁,但你没有权限做这件事"(已登录但权限不足)。
2.4. 测试注解鉴权
重启项目后,我们用三个不同的账号来测试权限差异。
测试矩阵
| 接口 | admin | editor | user | 未登录 |
|---|
/test/loginRequired | ✅ | ✅ | ✅ | ❌ 401 |
/test/adminOnly | ✅ | ❌ 403 | ❌ 403 | ❌ 401 |
/test/adminOrEditor | ✅ | ✅ | ❌ 403 | ❌ 401 |
/test/canAddUser | ✅ | ❌ 403 | ❌ 403 | ❌ 401 |
/test/canManageUser | ✅ | ❌ 403 | ❌ 403 | ❌ 401 |
/test/canAddArticle | ❌ 403 | ✅ | ❌ 403 | ❌ 401 |
/test/adminOrCanDeleteUser | ✅ | ❌ 403 | ❌ 403 | ❌ 401 |
验证命令
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
| curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456"
curl -H "satoken: token-admin" "http://localhost:8081/test/loginRequired"
curl -H "satoken: token-admin" "http://localhost:8081/test/adminOnly"
curl -H "satoken: token-admin" "http://localhost:8081/test/canAddUser"
curl -H "satoken: token-admin" "http://localhost:8081/test/canAddArticle"
curl -X POST "http://localhost:8081/auth/login?username=editor&password=123456"
curl -H "satoken: token-editor" "http://localhost:8081/test/adminOnly"
curl -H "satoken: token-editor" "http://localhost:8081/test/canAddArticle"
curl -X POST "http://localhost:8081/auth/login?username=user&password=123456"
curl -H "satoken: token-user" "http://localhost:8081/test/canAddUser"
curl "http://localhost:8081/test/loginRequired"
|
2.5. 本节小结
本章注册了 SaInterceptor 拦截器使注解鉴权生效,创建了 PermissionTestController 演示了 @SaCheckLogin、@SaCheckRole、@SaCheckPermission、@SaCheckOr 四种鉴权注解的用法和 AND/OR 模式,并在全局异常处理器中补充了 NotPermissionException 和 NotRoleException 的处理。
| 要点 | 何时使用 | 关键动作 |
|---|
SaInterceptor 注册 | 项目使用注解鉴权时 | 在 WebMvcConfigurer 中注册拦截器 |
@SaCheckPermission + SaMode | 接口需要权限控制时 | 在方法上声明权限要求和校验模式 |
| 403 vs 401 状态码 | 异常处理中区分认证和授权 | 未登录返回 401,权限不足返回 403 |
第三章. 路由拦截鉴权:在配置中声明访问规则
注解鉴权适合细粒度的接口级控制——每个方法单独声明权限要求。但在实际项目中,很多接口的权限规则是按模块划分的:/admin/** 下的所有接口都需要 admin 角色,/auth/login 不需要登录,其余接口都需要登录。
如果给每个方法都加注解,不仅繁琐,而且容易遗漏。这时候就需要路由拦截鉴权——在一个配置类中,用路径匹配规则批量声明访问策略。
3.1. SaRouter:路由匹配引擎
Sa-Token 提供了 SaRouter 工具类,配合 SaInterceptor 拦截器,可以在请求到达 Controller 之前按路径进行鉴权。
让我们升级第二章中创建的 SaTokenConfig,在注册拦截器时加入路由级别的鉴权逻辑。
📄 文件:src/main/java/com/example/authsatoken/config/SaTokenConfig.java(修改)
将原来的空参 new SaInterceptor() 替换为带路由鉴权逻辑的版本:
好的,我继续完整输出,不再中断:
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
| package com.example.authsatoken.config;
import cn.dev33.satoken.interceptor.SaInterceptor; import cn.dev33.satoken.router.SaRouter; import cn.dev33.satoken.stp.StpUtil; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration public class SaTokenConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handle -> {
SaRouter.match("/**") .notMatch("/auth/login") .check(r -> StpUtil.checkLogin());
SaRouter.match("/admin/**") .check(r -> StpUtil.checkRole("admin"));
SaRouter.match("/user/**") .check(r -> StpUtil.checkPermission("user"));
})).addPathPatterns("/**"); } }
|
这段配置做了三件事:
第一条规则:所有请求都必须登录,但 /auth/login 除外。SaRouter.match("/**") 匹配所有路径,.notMatch("/auth/login") 排除登录接口,.check(r -> StpUtil.checkLogin()) 执行登录校验。这相当于给整个项目加了一道"登录门槛"。
第二条规则:/admin/** 下的接口额外要求 admin 角色。注意这条规则是在第一条之后执行的——请求会先通过登录校验,再通过角色校验。两条规则是 AND 关系。
第三条规则:/user/** 下的接口需要 user 权限。这里的 checkPermission("user") 会匹配所有以 user 开头的权限码(如 user:add、user:view),因为 Sa-Token 支持权限通配符(我们会在第五章详细讲解)。
SaRouter 的链式 API 非常灵活,支持多种匹配方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| SaRouter.match("/user/info").check(r -> StpUtil.checkLogin());
SaRouter.match("/admin/**").check(r -> StpUtil.checkRole("admin"));
SaRouter.match("/**") .notMatch("/auth/login", "/auth/register", "/public/**") .check(r -> StpUtil.checkLogin());
SaRouter.match("/user/**").check(r -> StpUtil.checkPermission("user")); SaRouter.match("/order/**").check(r -> StpUtil.checkPermission("order")); SaRouter.match("/goods/**").check(r -> StpUtil.checkPermission("goods"));
SaRouter.match("/article/**", "POST").check(r -> StpUtil.checkPermission("article:add")); SaRouter.match("/article/**", "DELETE").check(r -> StpUtil.checkPermission("article:delete"));
|
3.2. 路由鉴权 vs 注解鉴权:执行顺序与适用场景
现在我们的项目中同时存在两种鉴权方式。它们的执行顺序是:路由鉴权先执行,注解鉴权后执行。一个请求需要同时通过两道关卡才能到达 Controller 方法。
这两种方式各有适用场景:
| 维度 | 路由拦截鉴权 | 注解鉴权 |
|---|
| 配置位置 | 集中在 SaTokenConfig 中 | 分散在每个 Controller 方法上 |
| 粒度 | 按路径模块批量控制 | 按方法精确控制 |
| 适合场景 | “所有接口都要登录”、“某个模块需要某个角色” | “这个接口需要 user:delete 权限” |
| 维护方式 | 改一处配置影响一批接口 | 改一个注解只影响一个方法 |
| 遗漏风险 | 低(默认拦截所有) | 高(忘记加注解就没有校验) |
最佳实践是两者结合使用:路由鉴权负责"粗粒度"的全局策略(登录校验、模块级角色校验),注解鉴权负责"细粒度"的接口级权限控制。
以我们当前的项目为例:
- 路由鉴权:所有接口必须登录(排除
/auth/login),/admin/** 需要 admin 角色 - 注解鉴权:
/test/canAddUser 需要 user:add 权限,/test/canManageUser 需要 user:add + user:delete 权限
这样即使某个 Controller 方法忘记加注解,路由鉴权也能保证最基本的登录校验不会被绕过。
3.3. 测试路由鉴权
重启项目后验证路由鉴权的效果。
测试矩阵
| 场景 | 请求 | 预期响应 | 原因 |
|---|
| 未登录访问普通接口 | GET /auth/token | 401 “未提供 Token” | 路由规则 1 拦截 |
| 未登录访问登录接口 | POST /auth/login | 200 登录成功 | notMatch 排除 |
| user 访问 /admin | DELETE /admin/users/10001/sessions | 403 “缺少角色:admin” | 路由规则 2 拦截 |
| admin 访问 /admin | DELETE /admin/users/10001/sessions | 200 成功 | 通过路由规则 1 和 2 |
验证命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| curl "http://localhost:8081/auth/token"
curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456"
curl -X POST "http://localhost:8081/auth/login?username=user&password=123456" curl -H "satoken: token-user" -X DELETE "http://localhost:8081/admin/users/10001/sessions"
curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456" curl -H "satoken: token-admin" -X DELETE "http://localhost:8081/admin/users/10001/sessions"
|
路由鉴权中的 notMatch 路径不需要包含 context-path。即使你在 application.yml 中配置了 server.servlet.context-path,排除路径也只写接口本身的路径。
3.4. 本节小结
本章将 SaInterceptor 从纯注解模式升级为路由拦截 + 注解的混合模式,使用 SaRouter.match().notMatch().check() 链式 API 实现了全局登录校验和模块级角色校验,并对比了路由鉴权和注解鉴权的适用场景。
| 要点 | 何时使用 | 关键动作 |
|---|
SaRouter.match().check() | 需要按路径批量控制访问策略时 | 在 SaInterceptor 中配置路由规则 |
notMatch() 排除路径 | 登录接口、公开接口不需要鉴权时 | 将白名单路径传入 notMatch |
| 路由 + 注解混合使用 | 项目同时需要粗粒度和细粒度控制时 | 路由负责全局策略,注解负责接口级权限 |
第四章. 编程式鉴权:复杂业务场景的完整实现
前三章介绍的注解鉴权和路由拦截鉴权,都是在请求到达 Controller 方法之前进行校验——要么通过,要么拒绝,非黑即白。但真实业务中,权限判断往往不是"能不能访问这个接口"这么简单,而是"在接口内部,根据不同条件执行不同逻辑"。
举个例子:一个"编辑文章"接口,管理员可以编辑任何文章,普通用户只能编辑自己的文章。这个逻辑无法用注解表达——因为注解只能声明"需要什么权限",不能表达"如果有这个权限就走 A 分支,没有就走 B 分支"。
这就是编程式鉴权的用武之地——在业务代码中调用 Sa-Token 的 API,根据返回值动态决定行为。
4.1. has 系列 vs check 系列:两种 API 风格
Sa-Token 提供了两组编程式鉴权 API:
has 系列:返回 boolean,不抛异常,适合条件分支场景
1 2 3 4 5 6 7 8 9
| StpUtil.hasRole("admin"); StpUtil.hasRoleAnd("admin", "editor"); StpUtil.hasRoleOr("admin", "editor");
StpUtil.hasPermission("user:add"); StpUtil.hasPermissionAnd("user:add", "user:delete"); StpUtil.hasPermissionOr("user:add", "user:delete");
|
check 系列:不返回值,校验失败直接抛异常,适合"不满足就中断"的场景
1 2 3 4 5 6 7 8 9
| StpUtil.checkRole("admin"); StpUtil.checkRoleAnd("admin", "editor"); StpUtil.checkRoleOr("admin", "editor");
StpUtil.checkPermission("user:add"); StpUtil.checkPermissionAnd("user:add", "user:delete"); StpUtil.checkPermissionOr("user:add", "user:delete");
|
选择哪个取决于你的业务需求:
- 需要条件分支(if-else)→ 用
has - 需要直接中断(不满足就报错)→ 用
check
实际上,第三章路由鉴权中的 SaRouter.match("/admin/**").check(r -> StpUtil.checkRole("admin")) 就是在用 check 系列——路由鉴权的 Lambda 内部调用的正是这些编程式 API。
4.2. 实战案例一:文章管理系统的权限设计
让我们用一个完整的业务场景来体验编程式鉴权。假设我们正在开发一个文章管理系统,需求如下:
业务需求
- 作者可以创建文章(草稿状态)
- 作者可以提交文章审核
- 编辑可以审核文章(通过/拒绝)
- 管理员可以强制发布任何文章
- 作者只能撤回自己未审核的文章
- 编辑可以发布已审核的文章
这个需求涉及多个角色、多个状态、多个权限的组合判断,无法用简单的注解表达。我们需要在业务代码中动态判断。
首先创建文章模型和状态枚举:
📄 文件:src/main/java/com/example/authsatoken/model/Article.java(新建)
在 com.example.authsatoken 包下新建 model 目录,然后创建 Article.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.model;
public class Article { private Long id; private String title; private String content; private Long authorId; private ArticleStatus status; private Long reviewerId;
public Article(Long id, String title, Long authorId, ArticleStatus status) { this.id = id; this.title = title; this.authorId = authorId; this.status = status; }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public String getTitle() { return title; } public void setTitle(String title) { this.title = title; }
public String getContent() { return content; } public void setContent(String content) { this.content = content; }
public Long getAuthorId() { return authorId; } public void setAuthorId(Long authorId) { this.authorId = authorId; }
public ArticleStatus getStatus() { return status; } public void setStatus(ArticleStatus status) { this.status = status; }
public Long getReviewerId() { return reviewerId; } public void setReviewerId(Long reviewerId) { this.reviewerId = reviewerId; } }
|
📄 文件:src/main/java/com/example/authsatoken/model/ArticleStatus.java(新建)
1 2 3 4 5 6 7 8 9 10 11 12
| package com.example.authsatoken.model;
public enum ArticleStatus { DRAFT, PENDING, APPROVED, REJECTED, PUBLISHED }
|
然后创建文章控制器,实现完整的权限判断逻辑:
📄 文件:src/main/java/com/example/authsatoken/controller/ArticleController.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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201
| package com.example.authsatoken.controller;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.util.SaResult; import com.example.authsatoken.model.Article; import com.example.authsatoken.model.ArticleStatus; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.concurrent.ConcurrentHashMap;
@RestController @RequestMapping("/articles") public class ArticleController {
private final Map<Long, Article> articleDb = new ConcurrentHashMap<>(); private Long nextId = 1L;
@PostMapping public SaResult createArticle(@RequestParam String title) { long currentUserId = StpUtil.getLoginIdAsLong();
Article article = new Article(nextId++, title, currentUserId, ArticleStatus.DRAFT); articleDb.put(article.getId(), article);
return SaResult.ok("文章创建成功").setData(article.getId()); }
@PostMapping("/{id}/submit") public SaResult submitForReview(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (!article.getAuthorId().equals(currentUserId)) { return SaResult.error("只能提交自己的文章").setCode(403); }
if (article.getStatus() != ArticleStatus.DRAFT) { return SaResult.error("只有草稿状态的文章可以提交"); }
article.setStatus(ArticleStatus.PENDING); return SaResult.ok("文章已提交审核"); }
@PostMapping("/{id}/review") public SaResult reviewArticle(@PathVariable Long id, @RequestParam boolean approved) { if (!StpUtil.hasRole("editor") && !StpUtil.hasRole("admin")) { return SaResult.error("只有编辑或管理员可以审核文章").setCode(403); }
Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
if (article.getStatus() != ArticleStatus.PENDING) { return SaResult.error("只有待审核状态的文章可以审核"); }
long currentUserId = StpUtil.getLoginIdAsLong(); article.setReviewerId(currentUserId); article.setStatus(approved ? ArticleStatus.APPROVED : ArticleStatus.REJECTED);
return SaResult.ok(approved ? "文章审核通过" : "文章审核拒绝"); }
@PostMapping("/{id}/publish") public SaResult publishArticle(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRole("admin")) { article.setStatus(ArticleStatus.PUBLISHED); return SaResult.ok("管理员强制发布成功"); }
if (StpUtil.hasRole("editor")) { if (article.getStatus() != ArticleStatus.APPROVED) { return SaResult.error("只有已审核的文章可以发布"); } article.setStatus(ArticleStatus.PUBLISHED); return SaResult.ok("编辑发布成功"); }
return SaResult.error("作者不能自己发布文章,请提交审核").setCode(403); }
@PostMapping("/{id}/withdraw") public SaResult withdrawArticle(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRole("admin")) { article.setStatus(ArticleStatus.DRAFT); return SaResult.ok("管理员撤回成功"); }
if (!article.getAuthorId().equals(currentUserId)) { return SaResult.error("只能撤回自己的文章").setCode(403); }
if (article.getStatus() != ArticleStatus.DRAFT && article.getStatus() != ArticleStatus.PENDING) { return SaResult.error("只能撤回草稿或待审核状态的文章"); }
article.setStatus(ArticleStatus.DRAFT); return SaResult.ok("撤回成功"); }
@GetMapping("/{id}") public SaResult getArticle(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
if (article.getStatus() == ArticleStatus.PUBLISHED) { return SaResult.data(article); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRole("admin") || StpUtil.hasRole("editor")) { return SaResult.data(article); }
if (article.getAuthorId().equals(currentUserId)) { return SaResult.data(article); }
return SaResult.error("无权查看此文章").setCode(403); } }
|
这个控制器展示了编程式鉴权的核心价值:在业务逻辑中根据不同条件动态判断权限。每个方法都包含了复杂的权限判断逻辑,这些逻辑无法用简单的注解表达。
4.3. 实战案例二:订单管理的多角色协作
让我们再看一个更复杂的场景:订单管理系统中的多角色协作。
业务需求
- 用户可以创建订单
- 用户可以取消自己的未支付订单
- 财务可以审核订单
- 仓库可以发货
- 管理员可以强制取消任何订单
- 客服可以查看任何订单,但不能修改
创建订单模型和状态枚举:
📄 文件:src/main/java/com/example/authsatoken/model/Order.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
| package com.example.authsatoken.model;
import java.math.BigDecimal;
public class Order { private Long id; private Long userId; private BigDecimal amount; private OrderStatus status;
public Order(Long id, Long userId, BigDecimal amount, OrderStatus status) { this.id = id; this.userId = userId; this.amount = amount; this.status = status; }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount = amount; }
public OrderStatus getStatus() { return status; } public void setStatus(OrderStatus status) { this.status = status; } }
|
📄 文件:src/main/java/com/example/authsatoken/model/OrderStatus.java(新建)
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.example.authsatoken.model;
public enum OrderStatus { UNPAID, PAID, AUDITED, SHIPPED, COMPLETED, CANCELLED }
|
创建订单控制器:
📄 文件:src/main/java/com/example/authsatoken/controller/OrderController.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
| package com.example.authsatoken.config;
import cn.dev33.satoken.interceptor.SaInterceptor; import cn.dev33.satoken.router.SaRouter; import cn.dev33.satoken.stp.StpUtil; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration public class SaTokenConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handle -> {
SaRouter.match("/**") .notMatch("/auth/login") .check(r -> StpUtil.checkLogin());
SaRouter.match("/admin/**") .check(r -> StpUtil.checkRole("admin"));
SaRouter.match("/user/**") .check(r -> StpUtil.checkPermission("user"));
})).addPathPatterns("/**"); } }
|
这段配置做了三件事:
第一条规则:所有请求都必须登录,但 /auth/login 除外。SaRouter.match("/**") 匹配所有路径,.notMatch("/auth/login") 排除登录接口,.check(r -> StpUtil.checkLogin()) 执行登录校验。这相当于给整个项目加了一道"登录门槛"。
第二条规则:/admin/** 下的接口额外要求 admin 角色。注意这条规则是在第一条之后执行的——请求会先通过登录校验,再通过角色校验。两条规则是 AND 关系。
第三条规则:/user/** 下的接口需要 user 权限。这里的 checkPermission("user") 会匹配所有以 user 开头的权限码(如 user:add、user:view),因为 Sa-Token 支持权限通配符(我们会在第五章详细讲解)。
SaRouter 的链式 API 非常灵活,支持多种匹配方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| SaRouter.match("/user/info").check(r -> StpUtil.checkLogin());
SaRouter.match("/admin/**").check(r -> StpUtil.checkRole("admin"));
SaRouter.match("/**") .notMatch("/auth/login", "/auth/register", "/public/**") .check(r -> StpUtil.checkLogin());
SaRouter.match("/user/**").check(r -> StpUtil.checkPermission("user")); SaRouter.match("/order/**").check(r -> StpUtil.checkPermission("order")); SaRouter.match("/goods/**").check(r -> StpUtil.checkPermission("goods"));
SaRouter.match("/article/**", "POST").check(r -> StpUtil.checkPermission("article:add")); SaRouter.match("/article/**", "DELETE").check(r -> StpUtil.checkPermission("article:delete"));
|
3.2. 路由鉴权 vs 注解鉴权:执行顺序与适用场景
现在我们的项目中同时存在两种鉴权方式。它们的执行顺序是:路由鉴权先执行,注解鉴权后执行。一个请求需要同时通过两道关卡才能到达 Controller 方法。
这两种方式各有适用场景:
| 维度 | 路由拦截鉴权 | 注解鉴权 |
|---|
| 配置位置 | 集中在 SaTokenConfig 中 | 分散在每个 Controller 方法上 |
| 粒度 | 按路径模块批量控制 | 按方法精确控制 |
| 适合场景 | “所有接口都要登录”、“某个模块需要某个角色” | “这个接口需要 user:delete 权限” |
| 维护方式 | 改一处配置影响一批接口 | 改一个注解只影响一个方法 |
| 遗漏风险 | 低(默认拦截所有) | 高(忘记加注解就没有校验) |
最佳实践是两者结合使用:路由鉴权负责"粗粒度"的全局策略(登录校验、模块级角色校验),注解鉴权负责"细粒度"的接口级权限控制。
以我们当前的项目为例:
- 路由鉴权:所有接口必须登录(排除
/auth/login),/admin/** 需要 admin 角色 - 注解鉴权:
/test/canAddUser 需要 user:add 权限,/test/canManageUser 需要 user:add + user:delete 权限
这样即使某个 Controller 方法忘记加注解,路由鉴权也能保证最基本的登录校验不会被绕过。
3.3. 测试路由鉴权
重启项目后验证路由鉴权的效果。
测试矩阵
| 场景 | 请求 | 预期响应 | 原因 |
|---|
| 未登录访问普通接口 | GET /auth/token | 401 “未提供 Token” | 路由规则 1 拦截 |
| 未登录访问登录接口 | POST /auth/login | 200 登录成功 | notMatch 排除 |
| user 访问 /admin | DELETE /admin/users/10001/sessions | 403 “缺少角色:admin” | 路由规则 2 拦截 |
| admin 访问 /admin | DELETE /admin/users/10001/sessions | 200 成功 | 通过路由规则 1 和 2 |
验证命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| curl "http://localhost:8081/auth/token"
curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456"
curl -X POST "http://localhost:8081/auth/login?username=user&password=123456" curl -H "satoken: token-user" -X DELETE "http://localhost:8081/admin/users/10001/sessions"
curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456" curl -H "satoken: token-admin" -X DELETE "http://localhost:8081/admin/users/10001/sessions"
|
路由鉴权中的 notMatch 路径不需要包含 context-path。即使你在 application.yml 中配置了 server.servlet.context-path,排除路径也只写接口本身的路径。
3.4. 本节小结
本章将 SaInterceptor 从纯注解模式升级为路由拦截 + 注解的混合模式,使用 SaRouter.match().notMatch().check() 链式 API 实现了全局登录校验和模块级角色校验,并对比了路由鉴权和注解鉴权的适用场景。
| 要点 | 何时使用 | 关键动作 |
|---|
SaRouter.match().check() | 需要按路径批量控制访问策略时 | 在 SaInterceptor 中配置路由规则 |
notMatch() 排除路径 | 登录接口、公开接口不需要鉴权时 | 将白名单路径传入 notMatch |
| 路由 + 注解混合使用 | 项目同时需要粗粒度和细粒度控制时 | 路由负责全局策略,注解负责接口级权限 |
第四章. 编程式鉴权:复杂业务场景的完整实现
前三章介绍的注解鉴权和路由拦截鉴权,都是在请求到达 Controller 方法之前进行校验——要么通过,要么拒绝,非黑即白。但真实业务中,权限判断往往不是"能不能访问这个接口"这么简单,而是"在接口内部,根据不同条件执行不同逻辑"。
举个例子:一个"编辑文章"接口,管理员可以编辑任何文章,普通用户只能编辑自己的文章。这个逻辑无法用注解表达——因为注解只能声明"需要什么权限",不能表达"如果有这个权限就走 A 分支,没有就走 B 分支"。
这就是编程式鉴权的用武之地——在业务代码中调用 Sa-Token 的 API,根据返回值动态决定行为。
4.1. has 系列 vs check 系列:两种 API 风格
Sa-Token 提供了两组编程式鉴权 API:
has 系列:返回 boolean,不抛异常,适合条件分支场景
1 2 3 4 5 6 7 8 9
| StpUtil.hasRole("admin"); StpUtil.hasRoleAnd("admin", "editor"); StpUtil.hasRoleOr("admin", "editor");
StpUtil.hasPermission("user:add"); StpUtil.hasPermissionAnd("user:add", "user:delete"); StpUtil.hasPermissionOr("user:add", "user:delete");
|
check 系列:不返回值,校验失败直接抛异常,适合"不满足就中断"的场景
1 2 3 4 5 6 7 8 9
| StpUtil.checkRole("admin"); StpUtil.checkRoleAnd("admin", "editor"); StpUtil.checkRoleOr("admin", "editor");
StpUtil.checkPermission("user:add"); StpUtil.checkPermissionAnd("user:add", "user:delete"); StpUtil.checkPermissionOr("user:add", "user:delete");
|
选择哪个取决于你的业务需求:
- 需要条件分支(if-else)→ 用
has - 需要直接中断(不满足就报错)→ 用
check
实际上,第三章路由鉴权中的 SaRouter.match("/admin/**").check(r -> StpUtil.checkRole("admin")) 就是在用 check 系列——路由鉴权的 Lambda 内部调用的正是这些编程式 API。
4.2. 实战案例一:文章管理系统的权限设计
让我们用一个完整的业务场景来体验编程式鉴权。假设我们正在开发一个文章管理系统,需求如下:
业务需求
- 作者可以创建文章(草稿状态)
- 作者可以提交文章审核
- 编辑可以审核文章(通过/拒绝)
- 管理员可以强制发布任何文章
- 作者只能撤回自己未审核的文章
- 编辑可以发布已审核的文章
这个需求涉及多个角色、多个状态、多个权限的组合判断,无法用简单的注解表达。我们需要在业务代码中动态判断。
首先创建文章模型和状态枚举:
📄 文件:src/main/java/com/example/authsatoken/model/Article.java(新建)
在 com.example.authsatoken 包下新建 model 目录,然后创建 Article.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.model;
public class Article { private Long id; private String title; private String content; private Long authorId; private ArticleStatus status; private Long reviewerId;
public Article(Long id, String title, Long authorId, ArticleStatus status) { this.id = id; this.title = title; this.authorId = authorId; this.status = status; }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public String getTitle() { return title; } public void setTitle(String title) { this.title = title; }
public String getContent() { return content; } public void setContent(String content) { this.content = content; }
public Long getAuthorId() { return authorId; } public void setAuthorId(Long authorId) { this.authorId = authorId; }
public ArticleStatus getStatus() { return status; } public void setStatus(ArticleStatus status) { this.status = status; }
public Long getReviewerId() { return reviewerId; } public void setReviewerId(Long reviewerId) { this.reviewerId = reviewerId; } }
|
📄 文件:src/main/java/com/example/authsatoken/model/ArticleStatus.java(新建)
1 2 3 4 5 6 7 8 9 10 11 12
| package com.example.authsatoken.model;
public enum ArticleStatus { DRAFT, PENDING, APPROVED, REJECTED, PUBLISHED }
|
然后创建文章控制器,实现完整的权限判断逻辑:
📄 文件:src/main/java/com/example/authsatoken/controller/ArticleController.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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201
| package com.example.authsatoken.controller;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.util.SaResult; import com.example.authsatoken.model.Article; import com.example.authsatoken.model.ArticleStatus; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.concurrent.ConcurrentHashMap;
@RestController @RequestMapping("/articles") public class ArticleController {
private final Map<Long, Article> articleDb = new ConcurrentHashMap<>(); private Long nextId = 1L;
@PostMapping public SaResult createArticle(@RequestParam String title) { long currentUserId = StpUtil.getLoginIdAsLong();
Article article = new Article(nextId++, title, currentUserId, ArticleStatus.DRAFT); articleDb.put(article.getId(), article);
return SaResult.ok("文章创建成功").setData(article.getId()); }
@PostMapping("/{id}/submit") public SaResult submitForReview(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (!article.getAuthorId().equals(currentUserId)) { return SaResult.error("只能提交自己的文章").setCode(403); }
if (article.getStatus() != ArticleStatus.DRAFT) { return SaResult.error("只有草稿状态的文章可以提交"); }
article.setStatus(ArticleStatus.PENDING); return SaResult.ok("文章已提交审核"); }
@PostMapping("/{id}/review") public SaResult reviewArticle(@PathVariable Long id, @RequestParam boolean approved) { if (!StpUtil.hasRole("editor") && !StpUtil.hasRole("admin")) { return SaResult.error("只有编辑或管理员可以审核文章").setCode(403); }
Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
if (article.getStatus() != ArticleStatus.PENDING) { return SaResult.error("只有待审核状态的文章可以审核"); }
long currentUserId = StpUtil.getLoginIdAsLong(); article.setReviewerId(currentUserId); article.setStatus(approved ? ArticleStatus.APPROVED : ArticleStatus.REJECTED);
return SaResult.ok(approved ? "文章审核通过" : "文章审核拒绝"); }
@PostMapping("/{id}/publish") public SaResult publishArticle(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRole("admin")) { article.setStatus(ArticleStatus.PUBLISHED); return SaResult.ok("管理员强制发布成功"); }
if (StpUtil.hasRole("editor")) { if (article.getStatus() != ArticleStatus.APPROVED) { return SaResult.error("只有已审核的文章可以发布"); } article.setStatus(ArticleStatus.PUBLISHED); return SaResult.ok("编辑发布成功"); }
return SaResult.error("作者不能自己发布文章,请提交审核").setCode(403); }
@PostMapping("/{id}/withdraw") public SaResult withdrawArticle(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRole("admin")) { article.setStatus(ArticleStatus.DRAFT); return SaResult.ok("管理员撤回成功"); }
if (!article.getAuthorId().equals(currentUserId)) { return SaResult.error("只能撤回自己的文章").setCode(403); }
if (article.getStatus() != ArticleStatus.DRAFT && article.getStatus() != ArticleStatus.PENDING) { return SaResult.error("只能撤回草稿或待审核状态的文章"); }
article.setStatus(ArticleStatus.DRAFT); return SaResult.ok("撤回成功"); }
@GetMapping("/{id}") public SaResult getArticle(@PathVariable Long id) { Article article = articleDb.get(id); if (article == null) { return SaResult.error("文章不存在"); }
if (article.getStatus() == ArticleStatus.PUBLISHED) { return SaResult.data(article); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRole("admin") || StpUtil.hasRole("editor")) { return SaResult.data(article); }
if (article.getAuthorId().equals(currentUserId)) { return SaResult.data(article); }
return SaResult.error("无权查看此文章").setCode(403); } }
|
这个控制器展示了编程式鉴权的核心价值:在业务逻辑中根据不同条件动态判断权限。每个方法都包含了复杂的权限判断逻辑,这些逻辑无法用简单的注解表达。
4.3. 实战案例二:订单管理的多角色协作
让我们再看一个更复杂的场景:订单管理系统中的多角色协作。
业务需求
- 用户可以创建订单
- 用户可以取消自己的未支付订单
- 财务可以审核订单
- 仓库可以发货
- 管理员可以强制取消任何订单
- 客服可以查看任何订单,但不能修改
创建订单模型和状态枚举:
📄 文件:src/main/java/com/example/authsatoken/model/Order.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
| package com.example.authsatoken.model;
import java.math.BigDecimal;
public class Order { private Long id; private Long userId; private BigDecimal amount; private OrderStatus status;
public Order(Long id, Long userId, BigDecimal amount, OrderStatus status) { this.id = id; this.userId = userId; this.amount = amount; this.status = status; }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount = amount; }
public OrderStatus getStatus() { return status; } public void setStatus(OrderStatus status) { this.status = status; } }
|
📄 文件:src/main/java/com/example/authsatoken/model/OrderStatus.java(新建)
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.example.authsatoken.model;
public enum OrderStatus { UNPAID, PAID, AUDITED, SHIPPED, COMPLETED, CANCELLED }
|
创建订单控制器:
📄 文件:src/main/java/com/example/authsatoken/controller/OrderController.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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153
| package com.example.authsatoken.controller;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.util.SaResult; import com.example.authsatoken.model.Order; import com.example.authsatoken.model.OrderStatus; import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal; import java.util.Map; import java.util.concurrent.ConcurrentHashMap;
@RestController @RequestMapping("/orders") public class OrderController {
private final Map<Long, Order> orderDb = new ConcurrentHashMap<>(); private Long nextId = 1L;
@PostMapping public SaResult createOrder(@RequestParam BigDecimal amount) { long currentUserId = StpUtil.getLoginIdAsLong();
Order order = new Order(nextId++, currentUserId, amount, OrderStatus.UNPAID); orderDb.put(order.getId(), order);
return SaResult.ok("订单创建成功").setData(order.getId()); }
@PostMapping("/{id}/cancel") public SaResult cancelOrder(@PathVariable Long id) { Order order = orderDb.get(id); if (order == null) { return SaResult.error("订单不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRole("admin")) { order.setStatus(OrderStatus.CANCELLED); return SaResult.ok("管理员强制取消成功"); }
if (!order.getUserId().equals(currentUserId)) { return SaResult.error("只能取消自己的订单").setCode(403); }
if (order.getStatus() != OrderStatus.UNPAID) { return SaResult.error("只能取消未支付的订单"); }
order.setStatus(OrderStatus.CANCELLED); return SaResult.ok("订单取消成功"); }
@PostMapping("/{id}/audit") public SaResult auditOrder(@PathVariable Long id) { if (!StpUtil.hasRoleOr("finance", "admin")) { return SaResult.error("只有财务或管理员可以审核订单").setCode(403); }
Order order = orderDb.get(id); if (order == null) { return SaResult.error("订单不存在"); }
if (order.getStatus() != OrderStatus.PAID) { return SaResult.error("只有已支付的订单可以审核"); }
order.setStatus(OrderStatus.AUDITED); return SaResult.ok("订单审核通过"); }
@PostMapping("/{id}/ship") public SaResult shipOrder(@PathVariable Long id) { if (!StpUtil.hasRoleOr("warehouse", "admin")) { return SaResult.error("只有仓库或管理员可以发货").setCode(403); }
Order order = orderDb.get(id); if (order == null) { return SaResult.error("订单不存在"); }
if (order.getStatus() != OrderStatus.AUDITED) { return SaResult.error("只有已审核的订单可以发货"); }
order.setStatus(OrderStatus.SHIPPED); return SaResult.ok("订单已发货"); }
@GetMapping("/{id}") public SaResult getOrder(@PathVariable Long id) { Order order = orderDb.get(id); if (order == null) { return SaResult.error("订单不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRoleOr("admin", "customer-service", "finance", "warehouse")) { return SaResult.data(order); }
if (order.getUserId().equals(currentUserId)) { return SaResult.data(order); }
return SaResult.error("无权查看此订单").setCode(403); } }
|
好的,我继续完整输出剩余内容:
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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| if (order.getStatus() != OrderStatus.UNPAID) { return SaResult.error("只能取消未支付的订单"); }
order.setStatus(OrderStatus.CANCELLED); return SaResult.ok("订单取消成功"); }
@PostMapping("/{id}/audit") public SaResult auditOrder(@PathVariable Long id) { if (!StpUtil.hasRoleOr("finance", "admin")) { return SaResult.error("只有财务或管理员可以审核订单").setCode(403); }
Order order = orderDb.get(id); if (order == null) { return SaResult.error("订单不存在"); }
if (order.getStatus() != OrderStatus.PAID) { return SaResult.error("只有已支付的订单可以审核"); }
order.setStatus(OrderStatus.AUDITED); return SaResult.ok("订单审核通过"); }
@PostMapping("/{id}/ship") public SaResult shipOrder(@PathVariable Long id) { if (!StpUtil.hasRoleOr("warehouse", "admin")) { return SaResult.error("只有仓库或管理员可以发货").setCode(403); }
Order order = orderDb.get(id); if (order == null) { return SaResult.error("订单不存在"); }
if (order.getStatus() != OrderStatus.AUDITED) { return SaResult.error("只有已审核的订单可以发货"); }
order.setStatus(OrderStatus.SHIPPED); return SaResult.ok("订单已发货"); }
@GetMapping("/{id}") public SaResult getOrder(@PathVariable Long id) { Order order = orderDb.get(id); if (order == null) { return SaResult.error("订单不存在"); }
long currentUserId = StpUtil.getLoginIdAsLong();
if (StpUtil.hasRoleOr("admin", "customer-service", "finance", "warehouse")) { return SaResult.data(order); }
if (order.getUserId().equals(currentUserId)) { return SaResult.data(order); }
return SaResult.error("无权查看此订单").setCode(403); } }
|
这个订单控制器展示了多角色协作场景下的权限判断。每个操作都有明确的角色要求,并且通过编程式鉴权在运行时动态判断。
4.4. 获取权限和角色列表
除了判断和校验,Sa-Token 还提供了直接获取权限列表和角色列表的 API:
1 2 3 4 5 6 7 8 9 10 11
| List<String> permissionList = StpUtil.getPermissionList();
List<String> roleList = StpUtil.getRoleList();
List<String> permissionList = StpUtil.getPermissionList(10001);
List<String> roleList = StpUtil.getRoleList(10001);
|
这些 API 的返回值就是我们在第一章 StpInterfaceImpl 中实现的 getPermissionList() 和 getRoleList() 方法的返回值。Sa-Token 在这里充当了一个"代理"——你调用 StpUtil.getPermissionList(),框架内部调用你实现的 StpInterfaceImpl.getPermissionList(loginId, loginType),然后把结果返回给你。
这在管理后台中很有用——比如"查看某个用户的权限详情"页面,直接调用 StpUtil.getPermissionList(userId) 就能拿到完整的权限列表。
让我们在 PermissionTestController 中新增一个接口来演示:
📄 文件:src/main/java/com/example/authsatoken/controller/PermissionTestController.java(修改)
在已有方法下方追加:
1 2 3 4 5 6 7 8 9 10
|
@GetMapping("/myPermissions") public SaResult getMyPermissions() { Map<String, Object> result = new HashMap<>(); result.put("roles", StpUtil.getRoleList()); result.put("permissions", StpUtil.getPermissionList()); return SaResult.data(result); }
|
测试这个接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456"
curl -H "satoken: token-admin" "http://localhost:8081/test/myPermissions"
|
4.5. 三种鉴权方式的完整对比与选择策略
到这里,我们已经学完了 Sa-Token 的三种鉴权方式。用一张表总结它们的适用场景:
| 鉴权方式 | 代码位置 | 粒度 | 适合场景 | 失败行为 | 示例 |
|---|
| 路由拦截 | SaTokenConfig 配置类 | 路径级 | 全局登录校验、模块级角色控制 | 抛异常 | “所有接口都要登录” |
| 注解鉴权 | Controller 方法上 | 方法级 | 接口级权限声明 | 抛异常 | “这个接口需要 user:delete 权限” |
| 编程式鉴权 | 业务代码中 | 代码级 | 条件分支、动态权限判断 | has 返回 boolean / check 抛异常 | “管理员可以编辑任何文章,普通用户只能编辑自己的” |
三者的执行顺序是:路由拦截 → 注解鉴权 → 编程式鉴权。一个请求需要依次通过前两道关卡才能到达 Controller 方法内部的编程式鉴权逻辑。
最佳实践:三者配合使用
- 路由拦截负责"底线"——所有接口必须登录,特定模块需要特定角色
- 注解鉴权负责"声明"——每个接口需要什么权限,一目了然
- 编程式鉴权负责"灵活"——业务逻辑中的动态权限判断
以我们的文章管理系统为例:
1 2 3
| 路由拦截:所有接口必须登录(排除 /auth/login) 注解鉴权:无(因为权限判断逻辑太复杂,注解无法表达) 编程式鉴权:在每个方法内部根据角色、文章状态、作者身份动态判断
|
以我们的订单管理系统为例:
1 2 3
| 路由拦截:所有接口必须登录 注解鉴权:无(因为需要根据订单状态和用户身份动态判断) 编程式鉴权:在每个方法内部根据角色、订单状态、下单用户动态判断
|
4.6. 本节小结
本章介绍了 Sa-Token 的编程式鉴权 API,区分了 has 系列(返回 boolean)和 check 系列(抛异常)的使用场景,通过文章管理系统和订单管理系统两个完整的业务案例,演示了如何在复杂业务场景中使用编程式鉴权实现动态权限判断,并总结了路由拦截、注解鉴权、编程式鉴权三种方式的选择策略。
| 要点 | 何时使用 | 关键动作 |
|---|
hasRole() / hasPermission() | 需要条件分支时 | 返回 boolean,用于 if-else 判断 |
checkRole() / checkPermission() | 需要直接中断时 | 校验失败抛异常,由全局处理器捕获 |
| 三种鉴权方式配合 | 项目权限体系设计时 | 路由负责底线,注解负责声明,编程式负责灵活 |
第五章. 权限通配符与 orRole:进阶权限设计
前四章覆盖了 Sa-Token 权限认证的三种鉴权方式。但在实际项目中,权限体系的设计还有两个常见需求没有解决:一是"超级管理员拥有所有权限"怎么表达?二是"有这个权限或者有那个角色都能访问"怎么声明?
Sa-Token 通过权限通配符和 orRole 参数优雅地解决了这两个问题。
5.1. 权限通配符:用 * 表达"所有"
在第一章的 StpInterfaceImpl 中,我们给 admin 角色分配了四个具体权限:user:add、user:delete、user:update、user:view。如果后续新增了 user:export、user:import 权限,就需要回来修改 Map——这在权限数量多的项目中非常繁琐。
Sa-Token 支持权限通配符 *,让你可以用一个字符串表达"某个模块的所有权限"甚至"所有模块的所有权限"。
通配符的匹配规则
Sa-Token 的通配符匹配规则是:按 : 分割后逐段比较,* 匹配该段的任意值。
1 2 3
| "*" // 匹配所有权限(超级管理员) "user:*" // 匹配 user 模块的所有操作(user:add、user:delete、user:view 等) "user:add" // 精确匹配(无通配符)
|
user:* 能匹配 user:add、user:delete,但不能匹配 order:add。而单独的 * 匹配一切。
让我们升级 StpInterfaceImpl 的权限映射来体验通配符:
📄 文件:src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java(修改)
将 ROLE_PERMISSION_MAP 改为使用通配符:
1 2 3 4 5 6 7 8
|
private static final Map<String, List<String>> ROLE_PERMISSION_MAP = Map.of( "admin", List.of("*"), "editor", List.of("article:*"), "user", List.of("user:view", "article:view") );
|
现在 admin 角色的权限列表只有一个元素 "*",但它能匹配任何权限码。当 Sa-Token 校验 user:add 时,发现权限列表中包含 *,自动判定为"拥有该权限"。
这种设计让权限分配变得非常灵活:
- 超级管理员:
*(拥有一切权限) - 用户模块管理员:
user:*(拥有用户模块的所有操作权限) - 文章模块管理员:
article:*(拥有文章模块的所有操作权限) - 普通用户:
user:view、article:view(只能查看)
通配符匹配发生在 Sa-Token 框架内部,你的 StpInterfaceImpl 只需要返回包含通配符的权限列表,框架会自动处理匹配逻辑。
测试通配符
重启项目后,以 admin 身份登录,然后访问任何需要权限的接口:
1 2 3 4 5 6 7 8 9 10 11 12
| curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456"
curl -H "satoken: token-admin" "http://localhost:8081/test/canAddUser"
curl -H "satoken: token-admin" "http://localhost:8081/test/canAddArticle"
|
5.2. orRole:权限与角色的混合校验
第二章中我们用 @SaCheckOr 实现了"拥有 admin 角色或拥有 user:delete 权限"的组合校验。但 @SaCheckOr 的写法比较冗长——需要嵌套两个注解。对于"有某个权限,或者有某个角色"这种最常见的混合校验,Sa-Token 提供了更简洁的 orRole 参数。
📄 文件:src/main/java/com/example/authsatoken/controller/PermissionTestController.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
|
@SaCheckPermission(value = "user:delete", orRole = "admin") @GetMapping("/deleteUser") public SaResult deleteUser() { return SaResult.ok("用户删除成功"); }
@SaCheckPermission(value = "article:delete", orRole = "admin, editor") @GetMapping("/deleteArticle") public SaResult deleteArticle() { return SaResult.ok("文章删除成功"); }
@SaCheckPermission(value = "article:publish", orRole = "admin & editor") @GetMapping("/publishArticle") public SaResult publishArticle() { return SaResult.ok("文章发布成功"); }
|
orRole = "admin" 的含义是:先校验权限 user:delete,如果没有这个权限,再校验是否拥有 admin 角色。两者满足其一即可通过。
orRole 还支持更复杂的表达式:
1 2 3 4 5
| @SaCheckPermission(value = "article:delete", orRole = "admin, editor")
@SaCheckPermission(value = "article:delete", orRole = "admin & editor")
|
对比 @SaCheckOr 和 orRole 的适用场景:
| 方式 | 写法 | 适合场景 |
|---|
orRole 参数 | @SaCheckPermission(value = "x", orRole = "y") | 权限 OR 角色(最常见) |
@SaCheckOr 注解 | @SaCheckOr(permission = ..., role = ...) | 多个权限条件 OR 多个角色条件(复杂组合) |
大多数业务场景用 orRole 就够了——"普通用户需要特定权限,管理员直接放行"是最典型的模式。只有当你需要组合多个权限条件和多个角色条件时,才需要用 @SaCheckOr。
测试 orRole
1 2 3 4 5 6 7 8 9 10 11
| curl -X POST "http://localhost:8081/auth/login?username=user&password=123456"
curl -H "satoken: token-user" "http://localhost:8081/test/deleteUser"
curl -X POST "http://localhost:8081/auth/login?username=admin&password=123456"
curl -H "satoken: token-admin" "http://localhost:8081/test/deleteUser"
|
5.3. 完整的权限体系设计建议
经过五章的学习,我们已经掌握了 Sa-Token 权限认证的全部核心能力。最后给出一个实际项目中的权限体系设计建议:
第一层:权限码设计
采用 模块:操作 的二级格式,必要时扩展为 模块:子模块:操作 的三级格式:
1 2 3 4 5 6 7 8 9 10 11 12
| user:add 用户新增 user:delete 用户删除 user:update 用户修改 user:view 用户查看 user:export 用户导出 article:add 文章新增 article:delete 文章删除 article:publish 文章发布 article:review 文章审核 order:create 订单创建 order:cancel 订单取消 order:refund 订单退款
|
第二层:角色设计
角色是权限的集合,通过角色间接分配权限:
1 2 3 4 5
| super-admin → *(所有权限) user-admin → user:*(用户模块所有权限) article-admin → article:*(文章模块所有权限) editor → article:add, article:update, article:review viewer → user:view, article:view, order:view
|
第三层:鉴权策略
1 2 3
| 路由拦截:所有接口必须登录,/admin/** 需要 admin 角色 注解鉴权:每个写操作接口声明所需权限 编程式鉴权:业务逻辑中的动态判断(如"只能编辑自己的文章")
|
这三层从数据到策略,构成了一个完整的权限认证体系。在实际项目中,第一层和第二层的数据通常存储在数据库中(用户表、角色表、权限表、用户-角色关联表、角色-权限关联表),通过 StpInterfaceImpl 查询数据库返回给 Sa-Token。我们在第一章中用硬编码 Map 模拟了这个过程,替换为数据库查询只需要修改 StpInterfaceImpl 的实现,其余代码完全不用动——这就是 StpInterface 接口解耦的价值。
数据库表结构示例
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
| CREATE TABLE sys_user ( id BIGINT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL );
CREATE TABLE sys_role ( id BIGINT PRIMARY KEY, role_code VARCHAR(50) NOT NULL, role_name VARCHAR(50) NOT NULL );
CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY, permission_code VARCHAR(100) NOT NULL, permission_name VARCHAR(100) NOT NULL );
CREATE TABLE sys_user_role ( user_id BIGINT, role_id BIGINT, PRIMARY KEY (user_id, role_id) );
CREATE TABLE sys_role_permission ( role_id BIGINT, permission_id BIGINT, PRIMARY KEY (role_id, permission_id) );
|
5.4. 本节小结
本章介绍了权限通配符 * 的匹配规则和使用场景,体验了 orRole 参数实现权限与角色的混合校验,并给出了实际项目中权限体系的三层设计建议和数据库表结构示例。
| 要点 | 何时使用 | 关键动作 |
|---|
权限通配符 * | 超级管理员或模块管理员需要"所有权限"时 | 在 StpInterfaceImpl 中返回 * 或 模块:* |
orRole 参数 | 接口需要"有权限或有角色"的混合校验时 | @SaCheckPermission(value = "x", orRole = "y") |
| 三层权限体系 | 项目权限架构设计时 | 权限码设计 → 角色设计 → 鉴权策略 |