《从零开发校园墙微信小程序》第 3 篇。登录是整个系统的入口:它决定了「当前是谁」,后面的投稿、评论、封禁、限流全都依赖它。

小程序登录的标准流程

小程序没有账号密码,登录靠微信:

小程序                      你的后端                       微信服务器
  │  wx.login()                │                              │
  │──── 拿到临时 code ─────────▶│                              │
  │      POST /user/login      │  code + appid + secret       │
  │                            │─────────────────────────────▶│
  │                            │◀──────── openid, session_key │
  │                            │  ① 首次登录自动注册用户       │
  │                            │  ② StpUtil.login(openid)     │
  │◀──── token 信息 ───────────│                              │
  │  存本地,之后每次请求带上     │                              │

关键点:

  • code 只能用一次、几分钟过期,只能在后端 拿它去换 openid(需要 AppSecret,绝不能放前端);
  • openid 是用户在这个小程序里的唯一标识,我们直接把它当作登录身份 。

后端登录接口

@PostMapping("/login")
@Operation(summary = "登录")
public Mono<Result> login(@RequestBody Map<String, String> params, ServerWebExchange exchange) {
    String code = params.get("code");
    return Mono.fromCallable(() -> {
        SaReactorSyncHolder.setContext(exchange);        // ← 重点 1
        try {
            // 微信侧登录并完成用户注册
            Map map = wxService.login(code);
            String openid = (String) map.get("openid");
            // 兜底注册:正常情况下上一步已写入用户表
            userService.registerforWeChat(openid);
            StpUtil.login(openid);                        // 建立会话
            SaTokenInfo token = StpUtil.getTokenInfo();
            return Result.success("登录成功", token);
        } finally {
            SaReactorSyncHolder.clearContext();           // ← 重点 2
        }
    }).subscribeOn(Schedulers.boundedElastic());          // ← 重点 3
}

三个「重点」就是这篇文章的核心,下面逐个讲。

重点 3:为什么要 boundedElastic

整个登录过程里有:调微信的 HTTP 接口、同步的 R2DBC .block()、Sa-Token 往 Redis 写会话。全是阻塞操作。上一篇说过,不能在 Netty 事件循环线程上阻塞 ,所以整体丢到 boundedElastic。

重点 1、2:Sa-Token 的 ThreadLocal 问题

这是我在 WebFlux 里踩得最深的坑。
Sa-Token 内部用 ThreadLocal 保存「当前请求的上下文」(请求头、响应头、Cookie 等),StpUtil.login() 要把 token 写回响应,就得读这个上下文。
问题来了:subscribeOn(boundedElastic) 会把后面的代码切换到另一条线程 ,而 ThreadLocal 不会跟着切 。结果就是:

SaTokenContextException: SaTokenContext 上下文尚未初始化

所以在新线程上要手动重建上下文 :SaReactorSyncHolder.setContext(exchange),用完再 clearContext(),防止线程池里的线程残留上一个请求的数据(串号!)。

治本:让上下文自动跟着线程走

每个接口都手写 setContext 太繁琐、还容易漏。项目里最终是两步「治本」:
第一步 :一个 WebFilter,在整个请求链 期间保持上下文,请求结束才清理:

@Configuration
public class SaTokenReactorConfig {
    @Bean
    public WebFilter saTokenContextBridgeFilter() {
        return (ServerWebExchange exchange, WebFilterChain chain) -> {
            SaReactorSyncHolder.setContext(exchange);
            SaTokenContextModelBox box = SaTokenContextForThreadLocalStaff.getModelBoxOrNull();
            return chain.filter(exchange)
                    .contextWrite(ctx -> ctx.put(SaTokenContextPropagationConfig.CONTEXT_KEY, box))
                    .doFinally(signalType -> SaReactorSyncHolder.clearContext());
        };
    }
}

第二步 :启用 Reactor 的自动上下文传播 (依赖 io.micrometer:context-propagation,并注册 Hooks.enableAutomaticContextPropagation() 与一个 ThreadLocalAccessor)。有了它,Reactor 在线程切换时会自动把 Sa-Token 的上下文从 Reactor Context 恢复到新线程的 ThreadLocal,业务代码不用再逐处 setContext。

这个「治本」是 2026 年 8 月一次线上事故后做的:某些接口漏写了 setContext,只在特定线程调度下才报 SaTokenContextException,非常难复现。ThreadLocal 在响应式里是定时炸弹 ,能自动化的一定要自动化。
注意:官方 demo 用的 SaReactorFilter 会「先 set、进 handler 前 clear」,和上面的桥接策略冲突,所以本项目不注册 SaReactorFilter,而是用 @SaCheckLogin 注解配合上面的桥接过滤器。

保护接口:@SaCheckLogin

登录之后,需要登录的接口只要加注解:

@SaCheckLogin
@PostMapping("/getUser")
public Mono<ResponseEntity<User>> getUser() {
    String openid = StpUtil.getLoginIdAsString();   // 当前登录人
    return userService.findByOpenidReactive(openid)
            .map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.ok().build());
}

未登录会抛出 Sa-Token 异常,由全局异常处理(GlobalExceptionHandler)转成 HTTP 401 +**{ code: 401 }**。

注意区分:业务规则不通过(比如投稿内容违规、被封号)也会用 code: 401 表示,但那是 HTTP 200 的正常响应。前端只有在 HTTP 状态码 也是 401 时才会触发重新登录,详见第 4 篇。

一条安全铁律:身份只信服务端

投稿接口里有这样一段注释,请务必记住:

// 注意:提交人 openid 一律取服务端 token 解析结果,忽略客户端传值,防伪造绕过封禁
String openid = StpUtil.getLoginIdAsString();

假设你让前端传一个 openid 参数,那么一个被封号的用户只要改一下参数,换成别人的 openid,就绕过了封禁和限流。任何「这是谁」的信息,只从 token 里取,永远不信请求参数。

Token 放哪里

Sa-Token 的 token 默认通过请求头传递,头的名字(tokenName)和值(tokenValue)都由登录接口返回。所以小程序端的做法是:

  1. 登录成功后,把 tokenName / tokenValue 存进本地存储;
  2. 每次请求从存储里读出来,按 header[tokenName] = tokenValue 组装请求头。

不要把头名写死在前端,让后端决定,以后要换名字不用发版。具体实现在下一篇的 requestWithAuth 里。

小结

  1. 小程序登录:wx.login 拿 code → 后端换 openid → StpUtil.login(openid);
  2. WebFlux 下,阻塞逻辑放 boundedElastic;ThreadLocal 上下文不会自动跟线程走 ,要么手动 setContext,要么用 WebFilter + context-propagation 自动化;
  3. finally 里一定要 clearContext();
  4. 身份只从 token 取 ,不信任客户端传值。

下一篇,我们进到小程序端:Taro 项目怎么组织,以及如何做到 token 过期时用户完全无感地自动重新登录 。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏