线上实时日志里连续出现了几批 401:有人从分享卡片进入详情页时,受保护的详情接口失败;另一些用户在执行需要登录的操作时,获取上传凭据的接口也一直失败。
最初很容易怀疑 JWT 过期、签名密钥变化,或者数据库里的会话被清理了。服务端日志给出的原因却很直接:
[Auth] missing_auth_header请求没有携带失效的 Token,而是根本没有 Authorization 请求头。
更有意思的是,同一个详情接口经常先返回 401,几秒后又变成 200:
17:27:41 GET /resources/42 401 missing_auth_header
17:27:44 GET /resources/42 200服务端没有重启,接口也没有时好时坏。变化发生在客户端:第一次请求发出时静默登录尚未完成,几秒后 Token 写入本地存储,下一次请求便正常通过。
问题不是某一行代码突然写坏了
我沿着 Git 历史回看,认证链路经历了几次演变。每次修改单独看都有理由,组合到一起才形成这个长期存在的竞态。
从普通 Hook 改成全局 Context
最早的实现直接在小程序入口调用 useAuth(),目的是让应用挂载时自动检查 Token:
function App({ children }) {
useAuth()
return children
}页面里也会调用 useAuth()。普通 Hook 每调用一次就有一套独立状态,所以入口和页面拿到的 user、loading 并不共享。随后代码改用 AuthProvider,这是正确的修复:
function App({ children }) {
return (
<AuthProvider>
{children}
</AuthProvider>
)
}当时的 AuthProvider 在 useEffect 中执行 checkAuth(),并提供 loading 给页面使用。不过 Provider 始终立即渲染 children。认证是否完成,要由各页面自己判断。
早期页面也确实这样使用:个人中心会在 loading 时显示加载页;首页则允许先渲染,把没有用户信息的状态显示成“访客”。那时应用以公开首页为主要入口,这种“页面先出现、登录在后台完成”的体验是说得通的。
问题在于,loading 只是一个可选状态,不是系统边界。后来新增的页面只要忘记检查它,就会在认证完成前发请求。
请求层采用了“有 Token 就带”的策略
请求封装统一后,所有请求都会读取本地存储:
const token = Taro.getStorageSync('token')
if (token) {
header.authorization = `Bearer ${token}`
}这段代码没有错,但它只负责附加 Token,并不负责保证 Token 已经准备好。
于是客户端出现了两条互不等待的异步链:
AuthProvider 挂载
-> useEffect
-> Taro.login()
-> POST /auth/login
-> 写入 Token
详情页挂载
-> useEffect
-> GET /resources/:id
-> 此时本地没有 Token谁先完成取决于网络和调度。页面请求通常更短,所以经常抢在微信登录之前到达服务端。
详情接口后来变成了受保护接口
详情接口最初可以匿名读取。后来为了同时返回与当前用户相关的状态,服务端需要知道请求者是谁,于是给 GET /resources/:id 加上了认证中间件。
这个变化在业务上合理,却改变了页面的启动前提:详情页不再是“先渲染也没关系”的公开页面,它必须等身份就绪。
接口权限变了,客户端的启动模型没有跟着变。AuthProvider 仍在后台登录,详情页仍在挂载后立即请求。
分享链接改成了直接进入目标页
早期分享路径先落到首页,并在查询参数里附带目标页面。后来为了让分享卡片直接打开详情页,路径改成了:
/pages/detail/index?id=42直接进入目标页是更自然的交互,但它拿掉了首页这层中转。用户从聊天卡片冷启动小程序时,详情页成为第一个挂载的页面,认证竞态从偶发现象变成了稳定可复现的问题。
这也解释了为什么开发过程中不容易发现:从首页点击进入详情页时,静默登录通常早已完成;只有清理进程后从分享卡片直接进入,才容易撞到空窗期。
后来的修复只处理了错误提示
之后还做过一次“静默重登”调整:/auth/me 返回 401 时不再弹出 Unauthorized,客户端清理旧 Token 后重新登录。这个修改解决的是提示时机,并没有改变页面和认证并发执行的事实。
再后来,请求层增加了 auth_failure 实时日志。它没有制造问题,只是终于把原本藏在用户操作里的 401 收集了起来。
所以这个故障会给人一种“最近才出现”的错觉。实际上竞态已经存在很久,只是在直接分享、受保护详情接口和实时日志都具备之后才完整暴露。
为什么部分受保护操作会一直失败
分享页的典型表现是先 401,稍后恢复。页面中的上传等受保护操作则可能连续几十分钟返回 401。
原因仍是同一条链路:页面的公开内容可以匿名浏览,操作按钮和表单却没有把“已完成认证”作为打开或提交的前置条件。如果启动时静默登录失败,页面照常可用,上传组件每次都会调用受保护的 /upload/token。请求层发现本地没有 Token,只能反复发送无认证请求。
此时仅在上传函数里加判断也不够。其他需要登录的读取和写入操作还会遇到同样的问题,认证准备状态必须成为全局约束。
修复:把认证放回应用启动边界
正确的边界在小程序入口。没有访问 Token 时,先完成全局静默登录,再挂载 AuthProvider 和任何页面:
function App({ children }) {
const [authReady, setAuthReady] = useState(() => Boolean(getAccessToken()))
useLaunch(() => {
if (!getAccessToken()) {
void recoverAuthSession('check_auth')
.then(() => setAuthReady(true))
}
})
return authReady ? (
<AuthProvider>{children}</AuthProvider>
) : null
}这里容易漏掉一个细节:只在 useLaunch 里调用异步登录仍然不够。React 不会等待这个 Promise,页面照样会立即渲染。必须用 authReady 控制页面是否挂载。
这不是在每个页面上覆盖一个全屏组件,而是在应用入口建立全局启动屏障。页面根本没有挂载,自然不会提前触发自己的 useEffect。
如果本地已有访问 Token,可以立即进入页面。Token 真正失效时,再由请求层处理续期。
续期不能继续依赖微信临时 code
Taro.login() 返回的 code 是一次性临时凭据,适合建立会话,不适合当作长期刷新机制。服务端应在登录时同时返回两类凭据:
- 访问 Token:用于普通业务请求,有效期较短;
- 刷新 Token:只发送给续期接口,有效期更长。
客户端收到业务 401 后执行下面的流程:
业务请求返回 401
-> 使用刷新 Token 调用 /auth/refresh
-> 保存新的访问 Token 和刷新 Token
-> 原业务请求重试一次
-> 刷新失败时再回退到 Taro.login()刷新 Token 每次使用后都要轮换。数据库只保存它的摘要,更新时把旧摘要放进 WHERE 条件,这样同一刷新 Token 被两个并发请求使用时只有一个能成功。
客户端同样需要“单飞”控制:多个业务请求同时遇到 401 时,共用一个恢复 Promise。否则会并发调用多次 Taro.login(),而微信临时 code 不能被当作可重复消费的普通字符串。
认证接口本身不能进入 401 自动重试,否则 /auth/refresh 失败后会递归调用自己。普通业务请求最多重试一次,第二次仍然失败就把错误交给上层。
如何验证这次修复
最有价值的验证不是普通首页启动,而是过去最容易失败的路径:
- 清理小程序进程和本地 Token;
- 从聊天里的分享卡片直接进入详情页;
- 观察网络请求顺序;
- 再测试多张图片同时上传和过期 Token 续期。
无 Token 冷启动时,请求顺序应为:
POST /auth/login 200
GET /resources/:id 200访问 Token 过期但刷新 Token 有效时,应为:
GET /resources/:id 401
POST /auth/refresh 200
GET /resources/:id 200第二种流程里的第一次 401 是正常的恢复信号。真正需要消失的是无 Token 冷启动产生的 missing_auth_header,以及同一批请求重复触发多个登录或续期。
这次问题留下的经验
AuthProvider 包住所有页面,只能说明状态作用域是全局的,不代表认证完成发生在页面渲染之前。
只要接口从匿名访问改成依赖当前用户,或者分享链接开始支持直接进入业务页,就应该重新检查启动顺序。页面能否挂载、请求能否发出、Token 能否恢复,是三件不同的事。把它们混在一个 useEffect 里,开发阶段通常看不出问题,冷启动会替你把顺序跑一遍。