《从零开发校园墙微信小程序》第 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)都由登录接口返回。所以小程序端的做法是:
- 登录成功后,把
tokenName/tokenValue存进本地存储; - 每次请求从存储里读出来,按
header[tokenName] = tokenValue组装请求头。
不要把头名写死在前端,让后端决定,以后要换名字不用发版。具体实现在下一篇的 requestWithAuth 里。
小结
- 小程序登录:
wx.login拿code→ 后端换openid→StpUtil.login(openid); - WebFlux 下,阻塞逻辑放
boundedElastic;ThreadLocal 上下文不会自动跟线程走 ,要么手动setContext,要么用WebFilter+context-propagation自动化; finally里一定要clearContext();- 身份只从 token 取 ,不信任客户端传值。
下一篇,我们进到小程序端:Taro 项目怎么组织,以及如何做到 token 过期时用户完全无感地自动重新登录 。