--- name: fix-full-audit-issues-p0-p2 overview: 按审查报告修复小程序全部已确认缺陷:5 项阻断(支付重复下单、支付轮询定时器泄漏、401 无限重试、uploadFile 无 401 处理、衣柜分享无出口且失效时白屏)、12 项一般(通知红点不同步、运势空卡与定时器、列表竞态与判空、11000 未处理、token 过期判断、请求无超时、uuid 缺失卡 loading 等)、8 项轻微(提交锁、死代码、硬编码 key、状态枚举等)。 todos: - id: request-core content: 加固 request.ts 与 auth.ts:重试上限、11000、超时、过期预刷新、uploadFile 登录校验 status: completed - id: paged-util content: 新增 utils/paged.ts 的 normalizePaged 并接入全部列表判空 status: completed - id: mall-pay content: 修复商城支付:confirmPay 提交锁、轮询定时器随页面卸载清理、按钮 loading status: completed dependencies: - request-core - id: share-detail content: 修复分享详情:uuid 兜底、错误态与去登录、过期校验、转发出口、定时器清理 status: completed dependencies: - request-core - id: list-race content: 为衣橱、穿搭、分享、订单、通知、商城加请求竞态保护与 loadingMore 拆分 status: completed dependencies: - paged-util - id: state-fix content: 修复运势错误态与重试、定时器与跨天、通知红点同步、衣物详情空态 status: completed dependencies: - request-core - id: submit-locks content: 修复穿搭编辑与分享创建:跨分类已选不丢、选衣分页、提交防重复与失败提示 status: completed dependencies: - paged-util - id: p2-cleanup content: 清理轻微项:资料保存锁与校验、跨零点、storage key、红点节流、倒计时、枚举、组件 data、死代码 status: completed dependencies: - request-core - id: verify-all content: 执行 tsc 类型检查并逐条复核修复效果 status: completed dependencies: - request-core - mall-pay - share-detail - list-race - state-fix - submit-locks - p2-cleanup --- ## 产品概述 微信小程序「纸条AI衣橱」。上一轮全链路审查输出了分级缺陷清单(阻断 5 项、一般 12 项、轻微 8 项),用户明确要求「全部修复」,即 P0 + P1 + P2 全量处理,不做取舍。 ## 核心修复项 - 支付与上传:商城下单加提交锁、支付轮询定时器随页面卸载清理、uploadFile 补齐登录校验与 401 处理、上传失败给明确提示 - 请求内核:401 重试加上限防请求风暴、补 11000 登录失效码、全请求超时兜底、token 过期主动预刷新、静默请求不清登录态(已修,保持) - 衣柜分享闭环:分享详情页补 uuid 空值兜底、错误态、本地过期/封禁校验、转发出口(onShareAppMessage + 分享按钮)、定时器清理 - 列表健壮性:统一分页空值兜底与请求竞态保护、拆分 loading 与 loadingMore,覆盖衣橱/穿搭/分享/订单/通知/商城 - 选择页与提交:穿搭编辑、衣柜分享创建的衣物选择跨分类不丢、列表分页、提交防重复 - 体验修复:运势页错误态与重试、定时器清理与跨天重算、通知已读后红点同步、衣物详情 uuid 缺失空态 - 轻微清理:资料保存锁与校验、生日选择器跨零点、硬编码 storage key 收编、红点节流失败回滚、订单倒计时走动、分享状态枚举化、组件 data 补齐、死代码移除 ## 效果验收 开发者工具清缓存重新编译后:无重复下单、无页面卸载后残留请求、弱网有超时提示、分享页可转发且无白屏、列表快速切换不串数据、运势失败可见可重试、通知已读后红点即时更新。 ## 技术栈 微信小程序原生框架 + TypeScript(`useCompilerPlugins: typescript`,基础库 3.17.2,渲染引擎已切回 webview),无测试框架,通过微信开发者工具编译调试。类型检查命令:`cd /root/zt-closet-wxmini-frontend && npx --yes -p typescript@5.4.5 tsc --noEmit -p tsconfig.json`。 ## 实现思路 分四层收敛,全部复用项目既有写法(Page 对象字面量 + setData + request 封装),不引入新模式: 1. **请求内核层(最优先,其余修复依赖它)** - 登录失效码收敛为常量集合 `11000/11001/11002/11004` 或 HTTP 401(`utils/config.ts:118-121` 已定义这些文案)。 - `doCall(retried)` 加重试标记:刷新成功后仅允许重试一次,第二次仍命中失效码则直接清 token + `handleAuthExpired()` + reject,杜绝无限「刷新→重试」风暴。 - `auth.ts` 新增 `isTokenExpired()`:单位自动兼容(`expireTime > 1e12` 视为毫秒,否则秒),留 30s 提前量;`isLoggedIn()` 语义保持不变(仅判断 token 存在),避免误杀。`request` 在 needAuth 且 token 已过期时先 `await doRefresh()` 再发请求,省掉一次 401 往返。 - 统一超时:业务请求与 refresh 15s,uploadFile 30s。 - `uploadFile` 补齐:未登录快速失败(复用 `NOT_LOGIN_CODE`)、登录失效码 → 清 token + `handleAuthExpired()`、支持 `showError`/`timeout` 参数。 2. **页面状态机层** - 新增 `utils/paged.ts` 提供 `normalizePaged`,统一 `(res && res.list) || []` 与 `(res && res.total) || 0` 兜底,消除 7 处重复且易漏的 `res.list.map`。 - 列表页统一三件套:`reqSeq`(响应序号不匹配即丢弃)、`loading`(首次/刷新)与 `loadingMore`(上拉)分离、`onUnload` 定时器清理。涉及 wardrobe、outfit、closet-share、order、notification、mall、closet-share-create、outfit-edit。 3. **防重复提交层** - 商城 `confirmPay`、衣柜分享创建 `onConfirm`、穿搭编辑 `onSave`、资料保存 `onSaveProfile` 统一加 `submitting`/`saving` 布尔闸门,入口处 return,catch 与成功分支复位,按钮文案联动。 4. **错误可见层** - 运势页新增 `error` 态 + 重试按钮(wxml 内容区由 `wx:else` 改为 `wx:elif` 并补错误分支);衣物详情 uuid 缺失走空态;分享详情页 `invalid` 支持标题/描述变量与「去登录」出口;upload 失败对非 80000 错误给 toast。 ## 实现要点(防回归) - 不得回退已有加固:`redirectToLogin` 幂等锁与「已在登录页跳过」、`silentAuth` 不清 token、`request` 未登录快速失败、`attached` 首帧 setData 延到 nextTick。 - `分享详情 /closet-share/:uuid` 是否免登录需后端确认,**保守保持 `auth: true`**,仅把 11001 单独映射为「去登录」按钮,避免擅自放开权限。 - `expire_time` 单位在 `types/models.ts` 注释标注为秒,本次用「大于 1e12 视为毫秒」的兼容判断兜底,无需后端确认即可安全上线。 - 定时器一律挂 `this` 并在 `onUnload` 清理;倒计时 tick 在 `onHide` 也要停,避免后台空转。 - 空态统一复用 `empty-state` 组件(项目已在 horoscope/mall 使用,属性:icon/title/desc/buttonText/bind:action),不新增组件。 - 日志与提示:不打印 token 等敏感信息;toast 文案沿用 `utils/config.ts` 的 `ERROR_MESSAGES` 风格。 ## 架构设计 ```mermaid flowchart TD A[页面/组件发起请求] --> B{needAuth 且无 token?} B -- 是 --> C[reject NOT_LOGIN 不发请求] B -- 否 --> D{token 已过期?} D -- 是 --> E[doRefresh 预刷新] D -- 否 --> F[wx.request timeout 15s] E -- 成功 --> F E -- 失败 --> G[清 token + handleAuthExpired + 幂等跳登录] F -- 命中登录失效码 --> H{已重试过?} H -- 否 --> I[doRefresh 后重试一次] H -- 是 --> G F -- 其它业务码 --> J[toast + reject] K[uploadFile] --> B ``` ## 目录结构 ``` zt-closet-wxmini-frontend/ ├── miniprogram/ │ ├── utils/ │ │ ├── request.ts # [MODIFY] 登录失效码集合常量;doCall(retried) 重试上限;11000 并入;统一 timeout;needAuth 过期预刷新;uploadFile 补登录校验/401/timeout/showError │ │ ├── auth.ts # [MODIFY] 新增 isTokenExpired()(秒/毫秒兼容 + 30s 提前量),isLoggedIn 语义不变 │ │ ├── paged.ts # [NEW] normalizePaged(res) 统一 list/total 空值兜底,供 7 处列表复用 │ │ └── config.ts # [MODIFY] STORAGE_KEYS 新增 OPEN_COLLECT,收编硬编码 'zt_open_collect' │ ├── custom-tab-bar/index.ts # [MODIFY] 节流打点改为请求成功后记录、失败回滚 lastSyncAt=0 │ ├── components/navigation-bar/navigation-bar.ts # [MODIFY] data 补齐 ios/innerPaddingRight/leftWidth/safeAreaTop │ ├── pages/ │ │ ├── mall/mall.ts # [MODIFY] confirmPay 加 submitting 锁;pollOrder timer 挂 this + onUnload 清理;fetchGoods/fetchFlow 用 normalizePaged │ │ ├── mall/mall.wxml # [MODIFY] 支付按钮 loading 文案与禁止连点 │ │ ├── upload/upload.ts # [MODIFY] catch 分支对非 80000 错误给 toast,保留额度弹窗 │ │ ├── closet-share-detail/closet-share-detail.ts # [MODIFY] uuid 空值兜底;本地过期/封禁校验;非 901xx 与 11001 分别落错误态/去登录态;onShareAppMessage;取消分享 timer 挂 this + onUnload 清理 │ │ ├── closet-share-detail/closet-share-detail.wxml # [MODIFY] empty-state 标题/描述变量化 + 去登录按钮 + open-type=share 分享按钮 │ │ ├── horoscope/horoscope.ts # [MODIFY] 新增 error 态与 onRetry;重试 timer 挂 this + onUnload 清理;跨天(stat_date 比对今日)重拉 │ │ ├── horoscope/horoscope.wxml # [MODIFY] 内容区改 wx:elif 并补错误态分支与重试按钮 │ │ ├── wardrobe/wardrobe.ts # [MODIFY] reqSeq + loadingMore + normalizePaged │ │ ├── outfit/outfit.ts # [MODIFY] 同上;storage key 改用 STORAGE_KEYS.OPEN_COLLECT │ │ ├── closet-share/closet-share.ts # [MODIFY] 同上;decorate 改用 ShareStatus 枚举 │ │ ├── closet-share/closet-share.wxml # [MODIFY] 已封禁状态文案分支 │ │ ├── order/order.ts # [MODIFY] 同上;待支付倒计时 1s tick + onUnload/onHide 清理 │ │ ├── notification/notification.ts # [MODIFY] 红点同步改为从 getCurrentPages 首个 tab 页取 getTabBar(),取不到仅写 storage;列表用 normalizePaged │ │ ├── clothes-detail/clothes-detail.ts # [MODIFY] uuid 为空直接 notFound 态,不再永久 loading │ │ ├── clothes-detail/clothes-detail.wxml # [MODIFY] 增加 notFound 空态 │ │ ├── outfit-edit/outfit-edit.ts # [MODIFY] selectedMap 累积跨分类已选;选衣列表触底分页;onSave 加 saving 锁 │ │ ├── closet-share-create/closet-share-create.ts # [MODIFY] 选衣列表触底分页;onConfirm 加 submitting 锁 + 失败 toast │ │ ├── profile/profile.ts # [MODIFY] showActionSheet 补 fail;today 改 onShow 重算;onSaveProfile 加 saving 锁 + trim + 无变更关闭 + 复位 avatarChanged;storage key 收编 │ │ └── outfit-detail/outfit-detail.ts # [MODIFY] 移除未使用的 OutfitSource 导入 │ └── app.ts # [MODIFY] 移除零引用的 globalData.userInfo(与 IAppOption 类型同步精简) ``` ## 关键代码结构 ```ts // miniprogram/utils/paged.ts —— 统一分页兜底,杜绝 list 为 null 时抛错被空 catch 吞掉 export interface NormalizedPaged { list: T[]; total: number } export function normalizePaged(res: any): NormalizedPaged // miniprogram/utils/auth.ts —— token 过期判断(秒/毫秒兼容,30s 提前量) export function isTokenExpired(): boolean // isLoggedIn() 语义保持不变:仅判断 access token 是否存在 // miniprogram/utils/request.ts —— 登录失效码收敛 + 重试上限 export const AUTH_EXPIRED_CODES: number[] // 11000 / 11001 / 11002 / 11004 export interface UploadOptions { showError?: boolean; timeout?: number } export function uploadFile(filePath: string, name?: string, url?: string, options?: UploadOptions): Promise ```