--- 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 ```