项目里接 Sa-Token 的时候发现这玩意儿用起来太省事了,把以前手写 JWT + 拦截器那一大坨全干掉了。这篇文章从 Sa-Token 怎么解决 JWT 的痛点讲起,到整合 Redis 后 token 存哪、权限怎么查,结合项目实际代码一次捋清楚。


前置阅读:如果你还不了解 JWT 以及它有什么问题,可以先看 JWT 学习笔记——从代码入手,拆原理、聊缺陷


Sa-Token 是什么

简单说:一个把 token 存到 Redis 里的权限框架。你的代码只需要调 StpUtil.login(userId),剩下的 token 生成、存储、校验、踢人、权限——全帮你干了。

全部自己搞

一行登录,注解鉴权

Sa-Token 模式

StpUtil.login(userId)

注解/配置鉴权

框架自动校验

Redis 自动管理

JWT 模式

自己生成 token

自己写拦截器

自己验签解析

自己管黑名单

几百行代码起步

几十行搞定

跟 JWT 最大的区别:Sa-Token 的 token 存在 Redis 里。所以你能随时踢人、冻结、查在线、改权限——服务端掌握主动权。


项目里怎么引入的

依赖

项目是 Spring Boot 3 + Spring Cloud Gateway 微服务架构。auth 模块负责登录,gateway 模块负责统一鉴权。两边的 starter 不一样,注意区分。

父 pom 统一管版本:

<!-- 父 pom.xml -->
<properties>
    <sa-token.version>1.45.0</sa-token.version>
</properties>

<dependencyManagement>
    <dependencies>
        <!-- Servlet 环境用这个(auth 模块) -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot3-starter</artifactId>
            <version>${sa-token.version}</version>
        </dependency>
        <!-- WebFlux/Gateway 环境用这个(gateway 模块) -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
            <version>${sa-token.version}</version>
        </dependency>
        <!-- 整合 Redis,用 Jackson 序列化 -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-redis-jackson</artifactId>
            <version>${sa-token.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

auth 模块(Servlet):

<!-- idle-store-auth/pom.xml -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<!-- Redis 整合 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
</dependency>

gateway 模块(WebFlux / Reactor):

<!-- idle-store-gateway/pom.xml -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
</dependency>
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
</dependency>

⚠️ 坑点:Servlet 环境和 WebFlux 环境的 starter 不一样。auth 模块用 sa-token-spring-boot3-starter,gateway 模块用 sa-token-reactor-spring-boot3-starter。搞反了依赖不报错,但功能不生效。

sa-token-redis-jackson 这个包干了什么?其实就是把 Sa-Token 默认的内存存储换成了 Redis,序列化用 Jackson。加上它就自动生效,不用额外配置——Sa-Token 会自动发现 RedisTemplate 并接管存储。

配置文件

auth 和 gateway 两边配置一样(网关需要鉴权所以要配一份):

############## Sa-Token 配置 ##############
sa-token:
  # token 名称(同时也是 Cookie 名称)
  token-name: Authorization
  # token 前缀
  token-prefix: Bearer
  # token 有效期(单位:秒)30 天
  timeout: 2592000
  # token 最低活跃频率,超过此时间没访问就冻结,-1 永不冻结
  active-timeout: -1
  # 是否允许同一账号多地同时登录
  is-concurrent: true
  # 多人登录同一账号时,是否共用一个 token
  is-share: false
  # token 生成风格:random-128 位随机字符串
  token-style: random-128
  # 是否打印操作日志
  is-log: true

每个配置项的含义:

配置 含义 项目里用的值 为什么
token-name 前端 Header 里传的字段名 Authorization 跟 RESTful 规范一致
token-prefix token 前面的前缀 Bearer 标准写法:Authorization: Bearer xxxxx
timeout 登录有效期 2592000(30天) 小程序端长期登录,不用反复授权
is-concurrent 允许多端同时登录 true 用户可以手机和电脑同时在线
is-share 多端登录共用同一 token false 每端独立 token,方便单独踢下线
token-style token 生成算法 random-128 128 位随机字符串,比 UUID 安全且紧凑

is-concurrent 和 is-share 到底怎么影响行为的

项目里配了 is-concurrent: true + is-share: false。这两个组合决定了多端登录时的行为。拆开看。

场景:同一个 userId=10001,先后在手机和电脑上登录。

💻 电脑后登录

StpUtil.login(10001)

is-concurrent: true → 允许,不踢手机

is-share: false → 不共用,生成新 token_B

Redis:
Authorization:token:token_A → 10001(还在)
Authorization:token:token_B → 10001(新增)
Authorization:login:token:10001 → token_A, token_B

📱 手机先登录

StpUtil.login(10001)

生成 token_A

Redis:
Authorization:token:token_A → 10001
Authorization:login:token:10001 → token_A

四种组合的区别:

is-concurrent is-share 效果
true true 多端可同时登录,共用一个 token
true false 多端可同时登录,各自独立 token。踢一端不影响另一端
false true 只允许一个设备在线,共用 token。新登录顶掉旧登录
false false 只允许一个设备在线,各自 token。新登录顶掉旧登录

项目选 true + false:用户可以手机和电脑同时在线,每端独立 token。StpUtil.logout() 退出只退当前设备,不会把另一个设备也踢了。


登录——一行代码就完事了

auth 服务里的 Controller

@RestController
@RequestMapping
public class UserController {

    @Resource
    private UserService userService;

    @PostMapping("/mini/login")
    public Response<MiniLoginRespVO> miniLoginAndRegister(
            @Validated @RequestBody MiniLoginReqVO miniLoginReqVO) {
        return userService.miniLoginAndRegister(miniLoginReqVO);
    }

    @PostMapping("/admin/login")
    public Response<AdminLoginRespVO> adminLogin(
            @Validated @RequestBody AdminLoginReqVO adminLoginReqVO) {
        return userService.adminLogin(adminLoginReqVO);
    }

    @PostMapping("/admin/logout")
    public Response<?> logout() {
        return userService.logout();
    }
}

Service——核心就两行

@Override
public Response<MiniLoginRespVO> miniLoginAndRegister(MiniLoginReqVO req) {
    String phone = req.getPhone();

    // 查数据库,用户不存在就注册
    UserDO user = userDOMapper.selectByPhone(phone);
    if (Objects.isNull(user)) {
        user = registerUser(phone);
    }

    // ******************** 核心就这两行 ********************
    StpUtil.login(user.getUserId());               // ① 登录
    String token = StpUtil.getTokenInfo().tokenValue; // ② 拿 token
    // ****************************************************

    // 把 token 和用户信息返回给前端
    return Response.success(MiniLoginRespVO.builder()
            .userId(user.getUserId())
            .token(token)
            .phone(phone)
            .build());
}

StpUtil.login(userId) 这一行干了什么?

Redis StpUtil 业务代码 Redis StpUtil 业务代码 StpUtil.login(10001) 生成一个随机 token (比如 "a1b2c3d4...") ① 存 token → userId 的映射 KEY: "Authorization:token:a1b2c3d4..." VALUE: "10001" ② 存 userId → token 的映射 KEY: "Authorization:login:token:10001" VALUE: "a1b2c3d4..." ③ 存用户的 Session KEY: "Authorization:session:10001" VALUE: (空的,等后续往里塞数据) token = "a1b2c3d4..."

那后续请求来了,Sa-Token 怎么知道是谁?

Redis Sa-Token 客户端 Redis Sa-Token 客户端 请求带 Header Authorization: Bearer a1b2c3d4... ① 从 config 知道 token-name=Authorization 从 Header 读出 "Bearer a1b2c3d4..." 去掉前缀 "Bearer " → "a1b2c3d4..." ② 查 token → userId GET Authorization:token:a1b2c3d4... "10001" ③ 查 userId → token(验证 token 确实属于这个用户) GET Authorization:login:token:10001 "a1b2c3d4..." ④ 对比两个 token 是否一致 ✅ 得到 loginId = 10001 ⑤ 后续 checkRole / checkPermission 就用这个 loginId 去调 StpInterface 查权限

所以 StpUtil.checkLogin() 背后就是:读 Header → 去 Redis 查 token → 验证 → 拿到 userId。全程自动,你一行都不用写。

登出——一行代码,Redis 里的东西全清掉

@Override
public Response<?> logout() {
    StpUtil.logout();  // 退出当前登录
    return Response.success();
}

就这么一行。但这一行背后干了什么?

Redis StpUtil 业务代码 Redis StpUtil 业务代码 StpUtil.logout() 从当前请求的 Header 里 拿到 token = "a1b2c3d4..." ① 删 token → userId DEL Authorization:token:a1b2c3d4... ② 从 userId 的 token 集合里移除当前 token (is-share:false 时一个用户可能有多个 token, 只删当前这个,不影响其他端的) ③ 删当前 token 关联的 Session (Authorization:session:10001 里的临时数据) 完成

跟 JWT 对比一下登出这件事:

JWT 登出:
  服务端不存 token → 没法"删"token → 登出只能靠客户端自己把 token 扔了
  → 但 token 本身还有效!任何人捡到它还能用!
  → 想真正让它失效?建黑名单表

Sa-Token 登出:
  Redis 里直接删 → token 瞬间失效 → 下次有人拿这个 token 来 → 查 Redis 查不到 → 未登录
logout() 和 logout(userId) 的区别
方法 干了什么 Redis 操作 什么时候用
StpUtil.logout() 踢当前这一端 只删当前请求带的这个 token 用户自己点退出
StpUtil.logout(userId) 踢这个用户所有端 把这个 userId 的所有 token 全删了 管理员强制踢人、用户改密码后踢掉所有设备

用项目配置(is-concurrent: true + is-share: false)举个具体例子:

用户 10001 同时在手机和电脑登录了:
  token_A(手机)──┐
                   ├── 都属于 userId=10001
  token_B(电脑)──┘

手机点退出 → StpUtil.logout()
  → Redis 里只删 token_A
  → 电脑上的 token_B 仍然有效 ✅

管理员踢人 → StpUtil.logout(10001)
  → Redis 里 token_A 和 token_B 全删
  → 手机和电脑都掉线
项目里的登出 Controller
@PostMapping("/admin/logout")
@ApiOperationLog(description = "管理员登出")
public Response<?> logout() {
    return userService.logout();
}

注意这个接口在网关里做了角色校验:

// SaTokenConfigure 里的配置
SaRouter.match("/auth/admin/logout",
        r -> StpUtil.checkRole("ROLE_COMMUNITY_ADMIN"));

也就是说:只有管理员角色才能调这个登出接口。普通用户的登出可以另外加一个接口,不做角色限制。


Token 存在 Redis 的哪里

加了 sa-token-redis-jackson 依赖后,token 就不再存内存了,全进 Redis。你可以直接连 Redis 看:

image-20260708194334562

我这里面因为设置多账号登陆不用同一个 token,所以两个地方登陆,生成的是两个 token

Redis

查 token → userId

用 userId 查 Session

查角色权限

Authorization:token:a1b2...

10001

Authorization:login:token:10001

a1b2...

Authorization:session:10001

{...}

请求带 Authorization
Bearer a1b2...

自定义 Redis Key
user:roles:10001
role:permissions:xxx

跟 JWT 的本质区别就在这:JWT 的 token 里直接编码了所有信息,服务端没存它,管不了。Sa-Token 把 token 存 Redis,随时能查、能删、能冻结——主动权在服务端。


网关怎么鉴权——拦截 + 角色校验

项目用 Spring Cloud Gateway 做统一入口,鉴权也放在网关层。这样请求还没到下游服务就已经被校验了。

全局过滤器

@Configuration
public class SaTokenConfigure {

    @Bean
    public SaReactorFilter getSaReactorFilter() {
        return new SaReactorFilter()
                // ① 拦截所有请求
                .addInclude("/**")
                // ② 排除的不拦截
                .addExclude("/favicon.ico")

                // ③ 鉴权逻辑:每次请求进来都会执行
                .setAuth(obj -> {

                    // 登录校验:除了登录、发验证码接口,其他都要求登录
                    SaRouter.match("/**")
                            .notMatch(SaHttpMethod.OPTIONS)
                            .notMatch("/auth/mini/login")
                            .notMatch("/auth/admin/login")
                            .notMatch("/auth/verification/code/send")
                            .check(r -> StpUtil.checkLogin());

                    // 角色校验:管理端登出接口,要求管理员角色
                    SaRouter.match("/auth/admin/logout",
                            r -> StpUtil.checkRole("ROLE_COMMUNITY_ADMIN"));
                })

                // ④ 鉴权失败时的异常处理
                .setError(e -> {
                    if (e instanceof NotLoginException) {
                        throw new NotLoginException(e.getMessage(), null, null);
                    } else if (e instanceof NotPermissionException
                            || e instanceof NotRoleException) {
                        throw new NotPermissionException(e.getMessage());
                    } else {
                        throw new RuntimeException(e.getMessage());
                    }
                });
    }
}

执行的流程:

否(白名单)

请求进来
POST /auth/admin/logout
Header: Authorization: Bearer a1b2...

SaReactorFilter

路径匹配?

直接放行

StpUtil.checkLogin()
token 有效?

NotLoginException

checkRole('ROLE_xxx')
有该角色?

NotRoleException

放行到下游服务

GlobalExceptionHandler
返回 401 + JSON

全局异常处理

网关是 WebFlux 的,异常处理跟 Servlet 不一样,需要实现 ErrorWebExceptionHandler

@Component
@Slf4j
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        ServerHttpResponse response = exchange.getResponse();
        Response<?> result;

        if (ex instanceof NotLoginException) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);           // 401
            result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(),
                                   ex.getMessage());
        } else if (ex instanceof NotPermissionException) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);           // 401
            result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(),
                                   ResponseCodeEnum.UNAUTHORIZED.getErrorMessage());
        } else {
            result = Response.fail(ResponseCodeEnum.SYSTEM_ERROR);     // 500
        }

        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        return response.writeWith(Mono.fromSupplier(() -> {
            DataBufferFactory bufferFactory = response.bufferFactory();
            try {
                return bufferFactory.wrap(objectMapper.writeValueAsBytes(result));
            } catch (Exception e) {
                return bufferFactory.wrap(new byte[0]);
            }
        }));
    }
}

把 userId 透传给下游

网关鉴权通过后,下游服务怎么知道当前登录的是谁?在每个请求的 Header 里塞一个 userId

@Component
@Order(-99)  // 优先级很高,在其他过滤器之前执行
public class AddUserId2HeaderFilter implements GlobalFilter {

    private static final String HEADER_USER_ID = "userId";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 从 SaToken 上下文里拿到当前登录的 userId
        return SaReactorHolder.sync(() -> {
            try {
                return StpUtil.getLoginIdAsLong();
            } catch (Exception e) {
                return Optional.<Long>empty();
            }
        }).flatMap(userIdOptional -> {
            if (userIdOptional.isEmpty()) {
                return chain.filter(exchange);  // 没登录,直接放
            }
            Long userId = userIdOptional.get();

            // 往 Header 里加 userId,下游服务直接取
            ServerWebExchange newExchange = exchange.mutate()
                    .request(builder -> builder.headers(headers -> {
                        headers.remove(HEADER_USER_ID);    // 防止前端伪造
                        headers.add("userId", String.valueOf(userId));
                    }))
                    .build();

            return chain.filter(newExchange);
        });
    }
}

这样下游的 auth 服务就能直接从 Header 拿 userId,不用再解析 token:

// auth 服务里自己写的 Filter
String userId = request.getHeader("userId");
UserContext.setUser(Long.parseLong(userId));

优化思路

上面这套写法已经完全能跑了。这里聊两个后续可以优化的方向,不是必须改,只是当你项目规模上来了可以参考。

优化一:setError 直接返回结果,不走 re-throw

当前 setError 里是 re-throw 异常,交给全局 GlobalExceptionHandler 统一渲染 JSON。这条链路没毛病,但 Sa-Token 其实支持更短的写法——setError 直接返回 SaResult,框架自己就能渲染 JSON 响应:

// 当前写法:re-throw → GlobalExceptionHandler 渲染
.setError(e -> {
    if (e instanceof NotLoginException) {
        throw new NotLoginException(e.getMessage(), null, null);
    }
    // ...
});

// 更短的写法:setError 直接返回结果
.setError(e -> {
    if (e instanceof NotLoginException) {
        return SaResult.error(e.getMessage());
    }
    if (e instanceof NotPermissionException || e instanceof NotRoleException) {
        return SaResult.error("无此权限");
    }
    return SaResult.error("系统繁忙");
});

两种都行。用 re-throw + GlobalExceptionHandler 的好处是能让所有异常(包括 Sa-Token 之外的)走同一套 JSON 格式。用 return SaResult 的好处是少一层间接调用,GlobalExceptionHandler 只管真正的未知异常,职责更单一。

优化二:路由规则从代码抽到配置文件

现在白名单和角色规则是硬编码的:

.notMatch("/auth/mini/login")
.notMatch("/auth/admin/login")
.notMatch("/auth/verification/code/send")

项目小的时候完全够用。等后面接了订单服务、商品服务、用户服务……SaTokenConfigure 会越来越长。到时候可以抽到 yaml 里:

# 自定义配置
gateway:
  auth:
    white-list:
      - /auth/mini/login
      - /auth/admin/login
      - /auth/verification/code/send
    role-rules:
      ROLE_COMMUNITY_ADMIN:
        - /auth/admin/logout
        - /auth/admin/**

然后 SaTokenConfigure@ConfigurationProperties 读进来,循环注册。新增接口只改配置,不用动代码。


权限怎么查——从数据库同步到 Redis

整体流程

每次请求时

项目启动时(一次性)

同步

批量查

权限列表

返回

PushRolePermissions2RedisRunner

数据库 role 表

数据库 permission 表

数据库 role_permission 表

Redis
role:permissions:ROLE_COMMUNITY_ADMIN
→ ['goods:create', 'order:delete', ...]

网关鉴权

StpInterfaceImpl
getPermissionList()

启动时:把 RBAC 数据从数据库刷到 Redis

项目里用 ApplicationRunner 在启动的时候跑一次:

@Component
@RequiredArgsConstructor
public class PushRolePermissions2RedisRunner implements ApplicationRunner {

    private final RoleDOMapper roleDOMapper;
    private final RolePermissionDOMapper rolePermissionDOMapper;
    private final PermissionDOMapper permissionDOMapper;
    private final RedisTemplate<String, Object> redisTemplate;

    // 分布式锁标记——防止集群多个节点同时刷
    private static final String PUSH_PERMISSION_FLAG = "push.permission.flag";

    @Override
    public void run(ApplicationArguments args) {
        // 用 setIfAbsent 做分布式锁,一天内只刷一次
        boolean canPushed = redisTemplate.opsForValue()
                .setIfAbsent(PUSH_PERMISSION_FLAG, "1", 1, TimeUnit.DAYS);
        if (!canPushed) {
            return;  // 别的节点已经刷过了
        }

        // 1. 查所有启用的角色
        List<RoleDO> roles = roleDOMapper.selectEnabledList();
        List<Long> roleIds = roles.stream().map(RoleDO::getId).toList();

        // 2. 查这些角色对应的权限关系
        List<RolePermissionDO> rolePerms = rolePermissionDOMapper.selectByRoleIds(roleIds);

        // 3. 组装:角色 key → 该角色的权限 key 列表
        //    比如 "ROLE_COMMUNITY_ADMIN" → ["goods:create", "order:delete"]
        Map<String, List<String>> rolePermMap = /* 组装逻辑 */;

        // 4. 写入 Redis
        rolePermMap.forEach((roleKey, permissions) -> {
            String key = RedisKeyConstants.buildRolePermissionsKey(roleKey);
            // key: "role:permissions:ROLE_COMMUNITY_ADMIN"
            // value: ["goods:create", "order:delete", ...]
            redisTemplate.opsForValue().set(key, permissions);
        });
    }
}

运行时:StpInterfaceImpl 从 Redis 查权限

网关每次做 checkRole()checkPermission() 时,Sa-Token 会调 StpInterface 的实现类去拿当前用户的角色和权限:

@Component
@RequiredArgsConstructor
@Slf4j
public class StpInterfaceImpl implements StpInterface {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;

    /**
     * 返回此 loginId 拥有的权限列表
     * Sa-Token 每次做 checkPermission() 时会自动调这个方法
     */
    @Override
    @SneakyThrows
    public List<String> getPermissionList(Object loginId, String loginType) {
        log.info("## 获取用户权限列表, loginId: {}", loginId);
        Long userId = Long.valueOf(loginId.toString());

        // 1. 从 Redis 查该用户的角色列表
        //    key: "user:roles:10001"  →  value: ["ROLE_COMMUNITY_ADMIN"]
        String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
        String userRolesValue = redisTemplate.opsForValue().get(userRolesKey).toString();

        if (StringUtils.isBlank(userRolesValue)) {
            return Collections.emptyList();
        }

        // JSON 字符串 → List<String>
        // "[\"ROLE_COMMUNITY_ADMIN\"]" → ["ROLE_COMMUNITY_ADMIN"]
        List<String> userRoleKeys = objectMapper.readValue(userRolesValue,
                new TypeReference<List<String>>() {});

        if (CollUtil.isEmpty(userRoleKeys)) {
            return Collections.emptyList();
        }

        // 2. 根据角色,批量查每个角色的权限列表
        //    "role:permissions:ROLE_COMMUNITY_ADMIN" → ["goods:create", "order:delete", ...]
        List<String> rolePermKeys = userRoleKeys.stream()
                .map(RedisKeyConstants::buildRolePermissionsKey)
                .toList();

        // 3. 用 multiGet 批量查 Redis,一次网络往返拿完所有角色的权限
        List<String> permValues = redisTemplate.opsForValue()
                .multiGet(rolePermKeys)
                .stream()
                .map(Object::toString)
                .toList();

        if (CollUtil.isEmpty(permValues)) {
            return Collections.emptyList();
        }

        // 4. 合并所有角色的权限到一个集合里
        List<String> allPermissions = new ArrayList<>();
        for (String jsonValue : permValues) {
            List<String> rolePermissions = objectMapper.readValue(jsonValue,
                    new TypeReference<List<String>>() {});
            allPermissions.addAll(rolePermissions);
        }

        return allPermissions;
    }

    /**
     * 返回此 loginId 拥有的角色列表
     * Sa-Token 每次做 checkRole() 时会自动调这个方法
     */
    @Override
    @SneakyThrows
    public List<String> getRoleList(Object loginId, String loginType) {
        log.info("## 获取用户角色列表, loginId: {}", loginId);
        Long userId = Long.valueOf(loginId.toString());

        String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
        List<String> userRolesValue = Convert.toList(String.class,
                redisTemplate.opsForValue().get(userRolesKey));

        if (CollUtil.isEmpty(userRolesValue)) {
            return Collections.emptyList();
        }
        return userRolesValue;
    }
}

这两个方法的调用时机:网关里写了 StpUtil.checkRole("ROLE_COMMUNITY_ADMIN") 时,Sa-Token 会先调 getRoleList() 拿到用户的所有角色,再判断目标角色在不在里面。checkPermission() 同理,调 getPermissionList()

为什么要用 multiGet?一个用户可能有多个角色,每个角色的权限存在不同的 Redis key 里。multiGet 一次网络往返把所有 key 查回来,比循环 get 少 N-1 次网络开销。

StpInterface 是 Sa-Token 留给你的扩展点。你不用写拦截器、不用写注解,只要实现这两个方法,框架在鉴权的时候自动来调。

用户注册时写入角色

private UserDO registerUser(String phone) {
    // ...插入 user 表...
    // ...插入 user_role 关联表...

    // 往 Redis 写入用户的角色列表
    // key: "user:roles:10001"  →  value: ["ROLE_COMMUNITY_USER"]
    List<String> roles = Lists.newArrayList(roleDO.getRoleKey());
    String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
    redisTemplate.opsForValue().set(userRolesKey, roles);
}

涉及到的 Redis Key

Key 格式 示例 谁写的 什么时候写
Authorization:token:xxx Authorization:token:a1b2c3... Sa-Token 框架 StpUtil.login() 时自动写
Authorization:login:token:userId Authorization:login:token:10001 Sa-Token 框架 StpUtil.login() 时自动写
Authorization:session:userId Authorization:session:10001 Sa-Token 框架 StpUtil.login() 时自动创建
user:roles:userId user:roles:10001 业务代码 注册时写入
role:permissions:roleKey role:permissions:ROLE_COMMUNITY_ADMIN PushRolePermissions2RedisRunner 项目启动时一次性同步

常用的 API 速查

项目里用到和可能会用到的:

登录 & 登出

// 登录——返回 token
StpUtil.login(userId);
String token = StpUtil.getTokenInfo().tokenValue;

// 登出——踢当前 token
StpUtil.logout();

// 登出——踢这个用户所有 token(所有设备都掉线)
StpUtil.logout(userId);

// 踢人下线(让指定 userId 的 token 全部失效)
StpUtil.kickout(userId);

// 封号(禁用指定 userId,所有 token 都不可用)
StpUtil.disable(userId, 3600);    // 封 1 小时
StpUtil.untieDisable(userId);     // 解封

获取当前用户

// 拿到当前登录用户的 userId
long userId = StpUtil.getLoginIdAsLong();

// 判断是否登录
boolean isLogin = StpUtil.isLogin();

鉴权

// 判断角色
StpUtil.hasRole("ROLE_COMMUNITY_ADMIN");      // true / false
StpUtil.checkRole("ROLE_COMMUNITY_ADMIN");    // false 时抛 NotRoleException

// 判断权限
StpUtil.hasPermission("goods:create");        // true / false
StpUtil.checkPermission("goods:create");      // false 时抛 NotPermissionException

完整流程串一遍

MySQL Redis Auth 服务 (UserServiceImpl) Gateway (SaTokenConfigure) 客户端 MySQL Redis Auth 服务 (UserServiceImpl) Gateway (SaTokenConfigure) 客户端 ===== 项目启动时 ===== ===== 登录 ===== ===== 后续请求 ===== 查所有角色 & 权限 写入 role:permissions:* (PushRolePermissions2RedisRunner) POST /auth/admin/login {phone, code} 校验用户 StpUtil.login(userId) ① 存 token → userId ② 存 userId → token ③ 创建 Session 返回 token POST /auth/admin/logout Header: Authorization: Bearer xxx 查 token → userId ✅ 查 userId → token ✅(验证 token 匹配) 查 user:roles:userId → ["ROLE_COMMUNITY_ADMIN"] 查 role:permissions:ROLE_COMMUNITY_ADMIN checkRole("ROLE_COMMUNITY_ADMIN") ✅ Header 加 userId 转发请求(带 userId) StpUtil.logout() 删 token、清 Session 响应 响应
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐