Browse Source

fix(auth): 并发 401 改为排队重放,避免在途静默续期被自身打断

首页冷启动会并发打出多个 401(family/member/list、membership/my、
onboarding/progress 等)。修复前 _handle401 在 _reloginLock 为真时直接调
_clearAuthAndRedirect():第 1 个 401 刚启动 silentRelogin()(uni.login 异步),
第 2~N 个 401 立刻抹掉 token 并预约 1500ms 后 reLaunch 登录页,在途续期被打断,
表现为控制台只有 401、silentRelogin 无任何日志、用户被弹回登录页。

- 并发 401 入 _pending401 队列,续期成功后统一重放;成败仅由发起续期的
  那个 401 判断
- 重放带 _noRelogin:重放仍 401 则直接失败,避免续期/重放 ping-pong 死循环
- silentRelogin 无 openid 分支补 warn 日志(此前静默返回 null,无法定位)

测试:concurrent 401s queue behind one renewal、replayed request that still 401
fails without triggering another renewal(后者覆盖死循环防护)。
已验证回归有效性:临时还原旧逻辑后新用例即以线上症状失败。
Jest 178/0,audit 0 error。
liaoxg 4 days ago
parent
commit
d865f5ee5f
2 changed files with 106 additions and 17 deletions
  1. 61 0
      cfc-frontend/__tests__/api/api.test.js
  2. 45 17
      cfc-frontend/utils/api.js

+ 61 - 0
cfc-frontend/__tests__/api/api.test.js

@@ -216,6 +216,67 @@ describe('auth API', () => {
     expect(global.uni.showToast).not.toHaveBeenCalled()
   })
 
+  // 回归:首页冷启动会并发打出多个 401(family/member/list、membership/my、onboarding/progress…)。
+  // 修复前 _handle401 在 _reloginLock 为真时直接调 _clearAuthAndRedirect():
+  // 第 1 个 401 刚启动 silentRelogin()(uni.login 异步),第 2、3 个 401 立刻把 token 从
+  // storage 抹掉并预约 1500ms 后 reLaunch 登录页 → 在途续期被打断,
+  // 线上表现为「控制台只有 401、没有任何续期日志、用户被弹回登录页」。
+  test('concurrent 401s queue behind one renewal and all get replayed', async () => {
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    mockUniLoginCode('fresh-wxcode')
+    // 3 个业务请求同时 401 → 续期 1 次成功 → 3 个重放全部 200
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
+      { data: { code: 200, data: { ok: 'verify' } } },
+      { data: { code: 200, data: { ok: 'members' } } },
+      { data: { code: 200, data: { ok: 'membership' } } }
+    ])
+
+    const [verifyRes, memberRes, membershipRes] = await Promise.all([
+      api.verifyToken(),
+      api.getFamilyMemberList({}),
+      api.getMyMembership()
+    ])
+
+    // 三个请求都必须拿到成功结果(修复前后两个会被 reject)
+    expect(verifyRes.data.ok).toBe('verify')
+    expect(memberRes.data.ok).toBe('members')
+    expect(membershipRes.data.ok).toBe('membership')
+
+    const calls = global.uni.request.mock.calls.map((c) => c[0])
+    // 续期只发生一次,绝不能因并发 401 引发续期风暴
+    expect(calls.filter((c) => /\/api\/auth\/silent-login/.test(c.url)).length).toBe(1)
+    // token 必须是被续期后的新值,而不是被并发 401 抹掉
+    expect(global.uni._storage.token).toBe('new-jwt')
+    // 关键回归点:并发 401 不得触发「登录已过期」提示,也不得跳登录页
+    expect(global.uni.showToast).not.toHaveBeenCalled()
+    expect(global.uni.reLaunch).not.toHaveBeenCalled()
+  })
+
+  // 回归:重放请求若仍 401(刚签发的 token 依旧不可用),必须直接失败而不是重新入队,
+  // 否则「续期 → 重放 → 401 → 续期 → 重放」会 ping-pong 死循环。
+  test('replayed request that still 401 fails without triggering another renewal', async () => {
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    mockUniLoginCode('fresh-wxcode')
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
+      { data: { code: 401, message: 'Token无效或已过期', data: null } }
+    ])
+
+    await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
+
+    const calls = global.uni.request.mock.calls.map((c) => c[0])
+    // 只续期过一次,重放 401 不再触发第二次续期
+    expect(calls.filter((c) => /\/api\/auth\/silent-login/.test(c.url)).length).toBe(1)
+    expect(global.uni._storage.token).toBe('new-jwt')
+  })
+
   test('401 retry does not duplicate query params in the replayed request URL', async () => {
     global.uni._storage.openid = 'openid_abc'
     global.uni._storage.token = 'expired-token'

+ 45 - 17
cfc-frontend/utils/api.js

@@ -4,6 +4,8 @@ const BASE_URL = config.API_BASE_URL
 
 var _redirectingToLogin = false
 var _reloginLock = false
+// 续期在途期间收到的其他 401 请求,排队等待续期成功后统一重放(见 _handle401)
+var _pending401 = []
 
 // === 请求去重(Dedup)全局安全网 ===
 // 同 URL + method + body 在 DEDUP_WINDOW_MS 窗口内复用同一个 Promise,
@@ -126,37 +128,62 @@ function _applyLoginData(data) {
 }
 
 // 401 处理:优先静默续期(wx.login code2Session)并重放原请求;续期失败则回退跳登录页
+//
+// 关键:并发 401 必须「排队」而不是「清登录态」。
+// 首页冷启动一次会并发打出多个 401(family/member/list、membership/my、
+// onboarding/progress、home/app-library、guide-config/current ...)。
+// 早期实现在 _reloginLock 为真时直接调 _clearAuthAndRedirect(),于是:第 1 个 401
+// 刚启动 silentRelogin()(wx.login 是异步的),第 2~N 个 401 立刻把 token 从
+// storage 抹掉并预约 1500ms 后 reLaunch 到登录页 → 在途的续期还没回调就被打断,
+// 表现为「控制台只有 401、一条续期日志都没有、用户被弹回登录页」。
+// 续期失败与否只能由发起续期的那一个 401 判断,其它 401 只需排队等结果。
 function _handle401(url, method, data, options, resolve, reject, resData) {
-  // 续期接口自身 401 / 重试仍 401 时,避免递归续期死循环
+  // 已有续期在途:挂起本请求等待重放。绝不能在这里动登录态,否则会毁掉在途续期。
   if (_reloginLock) {
-    _clearAuthAndRedirect()
-    reject(resData)
+    console.warn('[API] 401 续期在途,挂起请求 ' + url)
+    _pending401.push({ url: url, method: method, data: data, options: options, resolve: resolve, reject: reject, resData: resData })
     return
   }
   _reloginLock = true
+  var queue = [{ url: url, method: method, data: data, options: options, resolve: resolve, reject: reject, resData: resData }]
+  _pending401 = queue
+
+  function flushAll(err) {
+    var q = _pending401
+    _pending401 = []
+    _reloginLock = false
+    q.forEach(function (item) {
+      if (err) item.reject(err)
+      else item.reject(item.resData)
+    })
+  }
+
   silentRelogin().then(function(loginData) {
     if (loginData && loginData.token) {
-      console.warn('[API] 401 静默续期成功,重放原请求 ' + url)
+      console.warn('[API] 401 静默续期成功,重放 ' + queue.length + ' 个挂起请求')
       _applyLoginData(loginData)
-      // 重放原请求;重试期间保持锁,防止重试仍 401 时再次触发续期
-      // 401 重放请求跳过去重(原请求已 reject,缓存中的 promise 已过期)
-      var _retryOpts = Object.assign({}, options, { _skipDedup: true })
-      request(url, method, data, _retryOpts).then(function(r) {
-        _reloginLock = false
-        resolve(r)
-      }, function(e) {
-        _reloginLock = false
-        reject(e)
+      // 重放:跳过去重(原 promise 已 reject,缓存已过期),
+      // 并带 _noRelogin —— 若重放仍 401 说明刚签发的 token 仍不可用,直接失败,
+      // 不能再触发新一轮续期,否则会 ping-pong 死循环。
+      var _retryOpts = { _skipDedup: true, _noRelogin: true }
+      var left = queue.length
+      queue.forEach(function (item) {
+        var opts = Object.assign({}, item.options, _retryOpts)
+        request(item.url, item.method, item.data, opts).then(function(r) {
+          item.resolve(r)
+          if (--left === 0) _reloginLock = false
+        }, function(e) {
+          item.reject(e)
+          if (--left === 0) _reloginLock = false
+        })
       })
     } else {
-      _reloginLock = false
+      flushAll(resData)
       _clearAuthAndRedirect()
-      reject(resData)
     }
   }).catch(function() {
-    _reloginLock = false
+    flushAll(resData)
     _clearAuthAndRedirect()
-    reject(resData)
   })
 }
 
@@ -176,6 +203,7 @@ export function silentRelogin() {
     // 无 openid 缓存 = 本设备从未成功登录过,属新用户,不做静默续期(直接引导手动登录)
     var cachedOpenid = uni.getStorageSync('openid')
     if (!cachedOpenid) {
+      console.warn('[Auth] 本地无 openid 缓存,跳过静默续期(需手动登录)')
       resolve(null)
       return
     }