bugfix:未登录循环注册

This commit is contained in:
2026-09-13 01:08:11 +08:00
parent 0a8d60d87f
commit f897e1a391
12 changed files with 268 additions and 38 deletions
@@ -0,0 +1,127 @@
---
name: fix-401-relaunch-loop
overview: 修复新用户未登录时 /notification/unread 401 引发的并发 reLaunch 风暴(routeDone webviewId not found):请求层做登录守卫 + 登录失效跳转去重,custom-tab-bar 与页面侧停止无意义轮询与重复请求。
todos:
- id: request-guard
content: 改造 request.ts:未登录快速失败、401 收敛、新增 silentAuth 标记
status: completed
- id: redirect-idempotent
content: 在 app.ts 实现幂等登录失效跳转(单一入口+并发锁+已在登录页跳过)
status: completed
dependencies:
- request-guard
- id: tabbar-unread
content: 重构 custom-tab-bar:attached 只读缓存,新增 setUnread 与 syncUnread
status: completed
dependencies:
- request-guard
- id: page-guards
content: 为 notification、profile、wardrobe、login 页面加登录守卫并改用 setUnread
status: completed
dependencies:
- tabbar-unread
- redirect-idempotent
- id: verify
content: 通读改动链路并做编译/类型检查,确认无 401 刷屏与重复 reLaunch
status: completed
dependencies:
- page-guards
---
## 用户诉求
新用户(无 token)一打开小程序就疯狂报错:
- 控制台刷屏 `GET https://zt-closet-backend.lzcwy.cn/api/v1/notification/unread 401 (Unauthorized)`
- 大量 `[Page route 错误(system error)] routeDone with a webviewId 125 is not found`
需要定位根因并修复,消除 401 刷屏与路由报错。
## 根因(已由代码核实)
1. 未登录也发鉴权请求:`custom-tab-bar/index.ts` 的 `attached()` 无条件调用 `refreshUnread() → fetchUnread()`,无 `auth.isLoggedIn()` 守卫;`request()` 默认 `auth: true`,无 token 时服务端直接 401。
2. 401 会触发全局跳转:`request.ts` 中 `needAuth && (code 11001/11002/11004 || statusCode 401)` → `doRefresh()`(无 refresh_token 直接失败)→ `auth.clearTokens() + redirectToLogin() → wx.reLaunch('/pages/login/login')`。`showError: false` 只压掉 toast,压不掉跳转,所以"静默轮询"也会触发 reLaunch。
3. 并发放大:tabBar 有 5 个 tab 页,custom-tab-bar 每页一个实例,`attached` 各发一次;`profile.onShow → syncTabBar → tabBar.refreshUnread()` 再叠加;`wardrobe.onShow`、`profile.onShow` 还会各自发 `/clothes`、`/quota/info`、`/user/info`。多个请求同时 401 → 同时多次 `wx.reLaunch`,路由互相打断,导致 `routeDone with a webviewId 125 is not found`;且在登录页仍会继续 reLaunch。
4. 重复请求链:`notification.ts` 的 `fetchUnread()` 成功后调用 `tabBar.refreshUnread()`,后者内部又 `fetchUnread()`,每次 onShow 多打一次接口。
## 核心改造目标
- 未登录时不再发起鉴权请求,从源头消灭 401。
- 登录失效跳转收敛为单一入口并做幂等去重(含"已在登录页则跳过"),杜绝并发 `reLaunch`。
- 非关键轮询(未读红点)401 只失败不跳登录页。
- custom-tab-bar 提供 `setUnread(n)`,去掉 `refreshUnread → fetchUnread` 的二次请求。
- tab 页 `onShow` 增加登录守卫,登录后进入页面才拉数据。
## 技术栈
微信小程序原生框架 + TypeScript(沿用现有 `Page`/`Component` 写法、现有 `request.ts` 请求层、`auth.ts` 登录态工具),不做框架引入或架构调整。
## 实现方案
采用「请求层收敛 + 调用点守卫」两层治理,最小改动面:
1. **请求层(`utils/request.ts`)**
- 新增 `silentAuth?: boolean`(非关键请求,401 只 `reject` 不触发全局跳登录),用于未读红点等轮询。
- `auth: true` 且 `!auth.isLoggedIn()` 时:直接 `reject(new ApiError(11001, 'NOT_LOGIN'))`,**不发请求、不跳登录页**(避免未登录场景下的请求风暴)。
- 401 分支只做 `clearTokens()` + `reject`,跳转统一交给 `onAuthExpired` 处理器(不再在 request.ts 内直接 `wx.reLaunch`)。
- 保留 `doRefresh()` 单例锁逻辑不变(只处理"已登录但 token 过期"分支)。
2. **全局跳转去重(`app.ts`)**
- 在 `onLaunch` 注入的 `setAuthExpiredHandler` 内做单一入口:
- `isRedirecting` 布尔锁 + 时间窗(如 1000ms)内只执行一次;
- 读取 `getCurrentPages()`,若栈顶已是 `pages/login/login` 则直接返回;
- 执行 `clearTokens() + clearUserInfo() + wx.reLaunch('/pages/login/login')`,在 `reLaunch` 的 `complete` 回调里释放锁。
- 这是消除 `routeDone with a webviewId ... is not found` 的关键(并发 reLaunch 互相打断 webview 生命周期)。
3. **custom-tab-bar(`custom-tab-bar/index.ts`)**
- `attached()` / `refreshUnread()` 只做「读缓存 `zt_closet_unread` → `setData({ unread })`」,不再自动发请求。
- 新增 `setUnread(n)` 方法供外部(notification 页)直接更新红点,切断二次请求链。
- 新增 `syncUnread()`:仅在 `auth.isLoggedIn()` 时才请求 `/notification/unread`(`silentAuth: true`),供登录成功后/页面 onShow 主动调用。
4. **调用点守卫**
- `pages/notification/notification.ts`:`onShow` 仅在已登录时 `refresh() + fetchUnread()`;`fetchUnread()` 改为 `silentAuth: true`,成功后调用 `tabBar.setUnread(count)`(不再 `refreshUnread()`)。
- `pages/wardrobe/wardrobe.ts`、`pages/profile/profile.ts`:在 `refresh()` / `fetchUser()` / `fetchQuota()` 前加 `auth.isLoggedIn()` 守卫;`profile.syncTabBar()` 改为「已登录才 `setUnread` 缓存值 + 不触发请求」。
- `pages/login/login.ts`:登录成功后 `wx.setStorageSync('zt_closet_unread', 0)`,保证进入 tab 页时红点有初值。
## 实现要点(防回归)
- `request()` 的 `auth: false` 请求(`/user/wxlogin`)不受任何改动影响,登录流程保持原样。
- 「未登录直接 reject」不会弹出 toast:`showError` 默认 true 会提示,因此该分支应固定以 `showError: false` 语义处理,或显式不弹 toast,避免登录页被 toast 刷屏。
- 跳转锁必须放在 `app.ts` 的 handler 里(唯一入口),不要在各页面重复实现,避免多套锁不一致。
- 不改 `app.json` 页面顺序、不动 tabBar 配置,影响面仅 4~6 个文件。
## 架构关系
```mermaid
flowchart TD
A[tab 页 onShow / custom-tab-bar attached] --> B{auth.isLoggedIn?}
B -- 否 --> C[不发请求,红点用缓存]
B -- 是 --> D[request 鉴权请求]
D -- 401 --> E{silentAuth?}
E -- 是 --> F[仅 reject,不跳转]
E -- 否 --> G[clearTokens + onAuthExpired]
G --> H{幂等锁 / 已在登录页?}
H -- 是 --> I[跳过]
H -- 否 --> J[reLaunch login(单例)]
```
## 目录结构
```
miniprogram/
├── utils/
│ ├── request.ts # [MODIFY] 未登录快速失败;401 收敛为 reject + onAuthExpired;新增 silentAuth 非关键请求标记
│ └── auth.ts # [只读参考] 复用 isLoggedIn/clearTokens/clearUserInfo
├── app.ts # [MODIFY] onAuthExpired 幂等跳转:单一入口 + 并发锁 + 已在登录页跳过
├── custom-tab-bar/
│ └── index.ts # [MODIFY] attached 只读缓存;新增 setUnread(n) 与 syncUnread()(仅登录态请求)
└── pages/
├── notification/notification.ts # [MODIFY] 登录守卫;silentAuth 请求;成功后调用 tabBar.setUnread
├── profile/profile.ts # [MODIFY] onShow 登录守卫;syncTabBar 不再触发请求
└── wardrobe/wardrobe.ts # [MODIFY] refresh 前加登录守卫
└── login/login.ts # [MODIFY] 登录成功后初始化未读缓存为 0
```
+3 -2
View File
@@ -8,11 +8,12 @@ App<IAppOption>({
}, },
onLaunch() { onLaunch() {
// 登录失效 / 账号禁用时统一清登录态并跳转登录页 // 登录失效 / 账号禁用时统一清登录态。
// 跳转登录页由 request.ts 的 redirectToLogin 单一入口负责(幂等去重),
// 避免多个请求同时 401 时并发 reLaunch 打断 webview 路由。
setAuthExpiredHandler(() => { setAuthExpiredHandler(() => {
auth.clearTokens() auth.clearTokens()
auth.clearUserInfo() auth.clearUserInfo()
wx.reLaunch({ url: '/pages/login/login' })
}) })
}, },
}) })
+31 -10
View File
@@ -1,4 +1,10 @@
import { request } from '../utils/request' import { request } from '../utils/request'
import * as auth from '../utils/auth'
import { STORAGE_KEYS } from '../utils/config'
// 未读红点轮询最小间隔,避免频繁切 tab 时重复打接口
const SYNC_INTERVAL = 30 * 1000
let lastSyncAt = 0
Component({ Component({
data: { data: {
@@ -15,25 +21,40 @@ Component({
lifetimes: { lifetimes: {
attached() { attached() {
this.refreshUnread() // 先用缓存渲染红点;未登录时不再发请求(否则会批量 401 并触发重定向)
const cached = (wx.getStorageSync(STORAGE_KEYS.UNREAD) as number) || 0
this.setData({ unread: cached })
this.syncUnread()
}, },
}, },
methods: { methods: {
refreshUnread() { // 直接更新红点(供页面在已知数量时调用,不再触发二次请求)
const unread = (wx.getStorageSync('zt_closet_unread') as number) || 0 setUnread(n: number) {
const unread = Number(n) || 0
wx.setStorageSync(STORAGE_KEYS.UNREAD, unread)
this.setData({ unread }) this.setData({ unread })
this.fetchUnread()
}, },
fetchUnread() {
request<{ unread_count: number }>({ url: '/notification/unread', showError: false }) // 拉取未读数:仅登录态才请求,且做节流
.then((res) => { syncUnread(force?: boolean) {
const unread = res.unread_count || 0 if (!auth.isLoggedIn()) {
wx.setStorageSync('zt_closet_unread', unread) this.setUnread(0)
this.setData({ unread }) return
}
const now = Date.now()
if (!force && now - lastSyncAt < SYNC_INTERVAL) return
lastSyncAt = now
request<{ unread_count: number }>({
url: '/notification/unread',
showError: false,
silentAuth: true,
}) })
.then((res) => this.setUnread(res.unread_count || 0))
.catch(() => {}) .catch(() => {})
}, },
switchTab(e: WechatMiniprogram.TouchEvent) { switchTab(e: WechatMiniprogram.TouchEvent) {
const { path, index } = e.currentTarget.dataset as { path: string; index: number } const { path, index } = e.currentTarget.dataset as { path: string; index: number }
wx.switchTab({ url: path }) wx.switchTab({ url: path })
@@ -1,4 +1,5 @@
import { request } from '../../utils/request' import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, ClosetShareItem } from '../../types/models' import { PagedResult, ClosetShareItem } from '../../types/models'
Page({ Page({
@@ -17,6 +18,11 @@ Page({
onShow() { onShow() {
this.syncTabBar() this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
redirectToLogin()
return
}
this.refresh() this.refresh()
}, },
+7 -1
View File
@@ -1,4 +1,5 @@
import { request } from '../../utils/request' import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { HoroscopeData } from '../../types/models' import { HoroscopeData } from '../../types/models'
const COLOR_MAP: Record<string, string> = { const COLOR_MAP: Record<string, string> = {
@@ -25,6 +26,11 @@ Page({
onShow() { onShow() {
this.syncTabBar() this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
redirectToLogin()
return
}
if (!this.data.horoscope) this.fetchHoroscope(0) if (!this.data.horoscope) this.fetchHoroscope(0)
}, },
+2
View File
@@ -48,6 +48,8 @@ Page({
expireTime: data.expire_time, expireTime: data.expire_time,
}) })
wx.setStorageSync(STORAGE_KEYS.USER_INFO, data.user_info) wx.setStorageSync(STORAGE_KEYS.USER_INFO, data.user_info)
// 新登录用户重置未读红点,进入 tab 页后由 tabBar 拉取真实值
wx.setStorageSync(STORAGE_KEYS.UNREAD, 0)
this.setData({ userInfo: data.user_info }) this.setData({ userInfo: data.user_info })
if (data.user_info.is_new_user) { if (data.user_info.is_new_user) {
this.showWelcomeGift(data.user_info) this.showWelcomeGift(data.user_info)
+17 -4
View File
@@ -1,5 +1,7 @@
import { request } from '../../utils/request' import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, NotificationItem } from '../../types/models' import { PagedResult, NotificationItem } from '../../types/models'
import { STORAGE_KEYS } from '../../utils/config'
import { formatRelativeTime } from '../../utils/format' import { formatRelativeTime } from '../../utils/format'
const FILTERS = [ const FILTERS = [
@@ -21,16 +23,27 @@ Page({
}, },
onShow() { onShow() {
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
redirectToLogin()
return
}
this.refresh() this.refresh()
this.fetchUnread() this.fetchUnread()
}, },
fetchUnread() { fetchUnread() {
request<{ unread_count: number }>({ url: '/notification/unread', showError: false }) request<{ unread_count: number }>({
url: '/notification/unread',
showError: false,
silentAuth: true,
})
.then((res) => { .then((res) => {
wx.setStorageSync('zt_closet_unread', res.unread_count) const unread = res.unread_count || 0
wx.setStorageSync(STORAGE_KEYS.UNREAD, unread)
const tabBar = (this as any).getTabBar && (this as any).getTabBar() const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar && tabBar.refreshUnread) tabBar.refreshUnread() // 直接更新红点,避免 tabBar 内部再发一次请求
if (tabBar && tabBar.setUnread) tabBar.setUnread(unread)
}) })
.catch(() => {}) .catch(() => {})
}, },
+7 -1
View File
@@ -1,4 +1,5 @@
import { request } from '../../utils/request' import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, OutfitItem } from '../../types/models' import { PagedResult, OutfitItem } from '../../types/models'
import { OutfitSource, IsPublic } from '../../utils/config' import { OutfitSource, IsPublic } from '../../utils/config'
@@ -15,6 +16,11 @@ Page({
onShow() { onShow() {
this.syncTabBar() this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
redirectToLogin()
return
}
const openCollect = wx.getStorageSync('zt_open_collect') const openCollect = wx.getStorageSync('zt_open_collect')
if (openCollect) { if (openCollect) {
wx.removeStorageSync('zt_open_collect') wx.removeStorageSync('zt_open_collect')
+9 -5
View File
@@ -1,4 +1,5 @@
import { request, uploadFile } from '../../utils/request' import { request, uploadFile, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { UserInfo, QuotaItem } from '../../types/models' import { UserInfo, QuotaItem } from '../../types/models'
import { QuotaType, STORAGE_KEYS } from '../../utils/config' import { QuotaType, STORAGE_KEYS } from '../../utils/config'
import { formatDate } from '../../utils/format' import { formatDate } from '../../utils/format'
@@ -46,16 +47,19 @@ Page({
onShow() { onShow() {
this.syncTabBar() this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
redirectToLogin()
return
}
this.fetchUser() this.fetchUser()
this.fetchQuota() this.fetchQuota()
}, },
syncTabBar() { syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar() const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) { // 红点由 tabBar 自身在登录态下同步,这里只更新选中态
tabBar.setData({ selected: 4 }) if (tabBar) tabBar.setData({ selected: 4 })
if (tabBar.refreshUnread) tabBar.refreshUnread()
}
}, },
fetchUser() { fetchUser() {
+7 -1
View File
@@ -1,4 +1,5 @@
import { request } from '../../utils/request' import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, ClothesItem, QuotaItem } from '../../types/models' import { PagedResult, ClothesItem, QuotaItem } from '../../types/models'
import { QuotaType } from '../../utils/config' import { QuotaType } from '../../utils/config'
@@ -28,6 +29,11 @@ Page({
onShow() { onShow() {
this.syncTabBar() this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
redirectToLogin()
return
}
this.refresh() this.refresh()
}, },
+1
View File
@@ -12,6 +12,7 @@ export const STORAGE_KEYS = {
REFRESH_TOKEN: 'zt_closet_refresh_token', REFRESH_TOKEN: 'zt_closet_refresh_token',
EXPIRE_TIME: 'zt_closet_expire_time', EXPIRE_TIME: 'zt_closet_expire_time',
USER_INFO: 'zt_closet_user_info', USER_INFO: 'zt_closet_user_info',
UNREAD: 'zt_closet_unread',
} }
// 衣物图片处理状态 // 衣物图片处理状态
+49 -12
View File
@@ -19,8 +19,12 @@ export interface RequestOptions {
header?: Record<string, any> header?: Record<string, any>
auth?: boolean // 默认 true,需要鉴权 auth?: boolean // 默认 true,需要鉴权
showError?: boolean // 默认 true,业务错误自动 toast showError?: boolean // 默认 true,业务错误自动 toast
silentAuth?: boolean // 非关键请求(如未读红点轮询):401 只失败,不触发全局跳登录
} }
// 未登录错误码:调用方可通过 e.code === 11001 区分
export const NOT_LOGIN_CODE = 11001
// 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖) // 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖)
let onAuthExpired: (() => void) | null = null let onAuthExpired: (() => void) | null = null
export function setAuthExpiredHandler(handler: () => void): void { export function setAuthExpiredHandler(handler: () => void): void {
@@ -76,12 +80,35 @@ function showToast(title: string): void {
wx.showToast({ title, icon: 'none', duration: 2000 }) wx.showToast({ title, icon: 'none', duration: 2000 })
} }
function redirectToLogin(): void { // 跳转登录页的幂等锁:并发的多个 401 只会触发一次路由,避免互相打断 webview 生命周期
if (onAuthExpired) { let redirecting = false
onAuthExpired()
} else { /**
wx.reLaunch({ url: '/pages/login/login' }) * 跳转登录页(全局唯一入口,幂等):
} * 1. 同一时间只允许一次 reLaunch,其余调用直接丢弃;
* 2. 当前已在登录页时不重复跳转。
*/
export function redirectToLogin(): void {
if (redirecting) return
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const current = pages[pages.length - 1]
if (current && current.route === 'pages/login/login') return
redirecting = true
wx.reLaunch({
url: '/pages/login/login',
complete: () => {
setTimeout(() => {
redirecting = false
}, 1000)
},
})
}
// 登录失效统一处理:先清理登录态(由 app.ts 注入),再走幂等跳转
function handleAuthExpired(): void {
if (onAuthExpired) onAuthExpired()
redirectToLogin()
} }
export function request<T = any>(options: RequestOptions): Promise<T> { export function request<T = any>(options: RequestOptions): Promise<T> {
@@ -92,11 +119,18 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
header = {}, header = {},
auth: needAuth = true, auth: needAuth = true,
showError = true, showError = true,
silentAuth = false,
} = options } = options
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}` const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
// 未登录:直接失败,不发请求,避免无 token 时批量 401 引发重定向风暴
if (needAuth && !auth.isLoggedIn()) {
reject(new ApiError(NOT_LOGIN_CODE, '未登录'))
return
}
const doCall = (): void => { const doCall = (): void => {
const finalHeader: Record<string, any> = { const finalHeader: Record<string, any> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -138,12 +172,15 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
doRefresh().then((ok) => { doRefresh().then((ok) => {
if (ok) { if (ok) {
doCall() doCall()
} else { return
auth.clearTokens()
redirectToLogin()
if (showError) showToast('登录已过期,请重新登录')
reject(new ApiError(code, body.msg || '登录已过期'))
} }
auth.clearTokens()
// 非关键请求(红点轮询等)静默失败,不打断用户当前页面
if (!silentAuth) {
handleAuthExpired()
if (showError) showToast('登录已过期,请重新登录')
}
reject(new ApiError(code, body.msg || '登录已过期'))
}) })
return return
} }
@@ -151,7 +188,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
// 账号禁用(HTTP 200,需单独处理) // 账号禁用(HTTP 200,需单独处理)
if (code === 21001) { if (code === 21001) {
auth.clearTokens() auth.clearTokens()
redirectToLogin() handleAuthExpired()
if (showError) showToast(ERROR_MESSAGES[21001] || '账号已被禁用') if (showError) showToast(ERROR_MESSAGES[21001] || '账号已被禁用')
reject(new ApiError(code, body.msg || '账号已被禁用')) reject(new ApiError(code, body.msg || '账号已被禁用'))
return return