Files
zt-closet-wxmini-frontend/.codebuddy/plans/图片本地缓存方案_f26b36d7.md
zhengchiliu f169777f4a feat(image-cache): 新增图片本地缓存并全站接入
引入 cached-image 组件与 image-cache 工具,实现签名 URL 稳定 key 剥离、downloadFile 落盘及 TTL/LRU 清理,替换 11 个页面与 clothes-card 共 22 处网络图片渲染点,减少 CDN 重复流量。
2026-09-26 19:49:54 +08:00

165 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: 图片本地缓存方案
overview: 为小程序新增图片本地文件缓存机制(签名 URL 稳定 key 剥离 + downloadFile 落盘 + TTL/LRU 治理),封装 cached-image 组件并替换全项目所有网络图片渲染点,显著降低 CDN 重复流量。
todos:
- id: create-image-cache
content: 新建 utils/image-cache.ts:稳定 key、双哈希落盘、并发队列去重、失败回退熔断、TTL 容量清扫
status: completed
- id: create-cached-image
content: 新建 components/cached-image 组件:占位后显示本地文件,透传 mode/lazy-load,布局与圆角零回归
status: completed
dependencies:
- create-image-cache
- id: replace-images
content: 替换 11 个页面与 clothes-card 共 22 处 image 并注册组件,逐处核对 mode 与 lazy-load 保持原样
status: completed
dependencies:
- create-cached-image
- id: verify-coverage
content: Use [subagent:code-explorer] 全仓复核网络图片接入无遗漏,本地临时图与包内素材未受影响
status: completed
dependencies:
- replace-images
- id: typecheck-lint
content: 运行 TS 严格类型检查与 lint,修复新增/改动文件问题,复查日志级别使用合规
status: completed
dependencies:
- replace-images
---
## 产品概述
为现有微信小程序(衣橱与穿搭社区)新增统一的图片本地缓存机制:网络图片首次加载后自动下载并持久化到本地,后续展示直接读取本地文件,避免图片被反复从 CDN 拉取,从源头降低流量。
## 核心功能
- 两级缓存:内存映射 + 本地文件持久化,自动完成下载、落盘与命中读取
- 签名地址兼容:按剥离签名/时间戳参数后的稳定地址命中缓存,地址参数每次变化也不再重复下载
- 省流量展示:缓存未命中时先显示骨架占位(复用现有骨架风格),下载完成后显示本地图片;每张图仅向 CDN 请求一次
- 失败兜底:下载失败自动回退为网络地址显示;下载域名未配置时自动熔断,功能完全不受影响
- 全站覆盖:衣橱、穿搭社区、AI 推荐、分享详情、个人资料等约 22 处网络图片统一接入
- 自动清理:按有效期与容量上限自动淘汰旧缓存,避免占满本地空间
- 视觉零变化:除短暂骨架占位外,图片尺寸、圆角、裁剪模式、懒加载表现与现状完全一致
## 现状与根因
- 全仓 22 处网络图片(头像、抠图衣物图、分享封面、拼图封面等)均直接绑定接口返回 URL 渲染,无任何本地缓存逻辑(已全仓检索 downloadFile / FileSystemManager / 预加载,均无结果)。
- 图片 URL 为带签名/时间戳的临时链接且每次请求会变化;微信 image 组件按「完整 URL(含 query)」做客户端缓存、CDN 也按 URL 缓存,签名一变即双双失效,导致同一张图被反复下载。
- wxss 中的 url(...) 均为内联 SVG,无 CSS 网络背景图;无 canvas 绘制、无保存到相册场景,改造面收敛在 image 标签内。
## 技术方案(Implementation Approach)
采用「核心缓存模块 + 通用图片组件」的前端两级缓存方案,落地路径如下:
1. 新增 `utils/image-cache.ts`(与 UI 无关的纯逻辑模块,通过 logger 输出日志):
- 稳定 key:剥离大小写不敏感的签名类参数(sign/signature/sig/t/ts/timestamp/time/expires/expire/auth/token/nonce/x-cos-security-token/q-sign-time 等,清单在模块头部集中维护并注释),其余 query 排序后与 host+path 拼接;保证「同一张图即使签名变化」也命中同一缓存条目。
- 落盘指纹:不引入任何依赖,自实现 FNV-1a + djb2 双 32 位哈希拼接为 16 位十六进制文件名(降低碰撞概率),扩展名从 URL pathname 提取(jpg/png/webp/gif,缺省 .jpg)。
- 存储位置:`${wx.env.USER_DATA_PATH}/imgcache/`(本地用户文件,随代码包清理,生命周期与代码包一致,安全可靠;总量上限 200MB,本方案自设 80MB 上限留足余量)。
- 命中判定:内存 Map(O(1))→ `fs.access`(异步)判断磁盘文件 → 均未命中才进入下载。
- 下载:`wx.downloadFile` 指定 `filePath` 直写目标文件(先惰性 mkdir 递归创建目录);并发队列上限 4;同一稳定 key 的任务去重(复用同一 Promise);statusCode 非 200 视为失败并删除半截文件。
- 失败与熔断:单图失败仅 debug 日志并让组件回退网络地址;若失败信息包含「url not in domain list」(downloadFile 合法域名未配置),会话级熔断禁用缓存流程,warn 一次(写实时日志)后全部直接回退原逻辑。
- 容量治理(异步、不阻塞渲染):TTL 7 天 + 容量 80MB / 300 个文件,超出时按文件 mtime 从旧到新删除;清扫节流触发(启动后首次图片解析 5 秒执行一次,运行期内每累计约 50 次成功落盘再执行一次),执行中不重复触发;文件删除失败忽略。
2. 新增 `components/cached-image` 通用图片组件(替换原 image 标签):
- 属性:`src`(网络地址或本地路径)、`mode`(默认 aspectFill)、`lazyLoad`(wxml 用 lazy-load 传入,默认 false,保持各调用点原行为)。
- 流程:src 非 https(wxfile://、http://tmp、http://usr 等本地临时/包内路径)直接渲染不进入缓存;https 地址先查内存/磁盘,命中直接显示本地文件(全程零网络请求);未命中显示骨架占位并进入下载队列,成功后显示本地文件。
- 关键约束:占位与下载期间绝不渲染原始网络 URL,避免 image 组件自发请求抵消缓存收益;仅下载失败后回退渲染网络地址,保证功能可用。
- 自愈:内层 image 触发 error 且当前为本地路径时,删除该缓存文件并回退网络地址一次(应对文件被系统清理)。
- 样式兼容(零布局回归):组件 wxss 用 `:host { display: inline-block; }` 对齐原 image 元素默认布局行为,内层 image `width/height: 100%; display: block; border-radius: inherit` 保证填满宿主并继承圆角;调用点原有 class(.pic 等尺寸体系)、mode、lazy-load 原样保留,class 落在组件宿主节点上,无需改动任何现有 wxss。
3. 接入范围:11 个页面 wxml + clothes-card 组件共 22 处 image 标签替换为 cached-image,并沿用现有注册惯例(`"cached-image": "/components/cached-image/cached-image"`)在对应页面/组件 json 的 usingComponents 中注册;wardrobe 页经 clothes-card 间接生效,upload 页本地预览、damo 包内素材、图标等一律不动。
## 系统架构
```mermaid
flowchart LR
A[cached-image 组件收到 src] --> B{是否 https 网络图?}
B -- 否 --> C[直接渲染本地路径]
B -- 是 --> D{内存命中?}
D -- 是 --> E[显示本地文件]
D -- 否 --> F{磁盘已存在?}
F -- 是 --> E
F -- 否 --> G[并发队列 downloadFile 落盘]
G -- 成功 --> E
G -- 失败 --> H[回退显示网络 URL]
```
## 目录结构
```
zt-closet-wxmini-frontend/miniprogram/
├── utils/
│ └── image-cache.ts # [NEW] 缓存核心:稳定 key 剥离、双哈希文件名、内存+磁盘命中、
│ # downloadFile 并发队列与去重、失败回退与域名熔断、
│ # TTL/容量清扫、logger 日志(LOG='image-cache')
├── components/
│ ├── cached-image/
│ │ ├── cached-image.ts # [NEW] 组件逻辑:属性定义、src 监听、本地/网络分流、占位与回退、
│ │ │ # 出错自愈(删坏文件并回退网络地址)
│ │ ├── cached-image.wxml # [NEW] 占位层 + 内层 image(mode/lazy-load 透传)
│ │ ├── cached-image.wxss # [NEW] :host 布局对齐、内层 100% 填充与 border-radius 继承、
│ │ │ # 骨架占位(对齐现有 .sk 视觉,局部 keyframes)
│ │ └── cached-image.json # [NEW] { "component": true }
│ └── clothes-card/
│ ├── clothes-card.wxml # [MODIFY] 1 处 image(衣物瓦片,lazy-load 保留)
│ └── clothes-card.json # [MODIFY] 注册 cached-image
├── pages/
│ ├── outfit/ # [MODIFY] outfit.wxml 4 处 + outfit.json 注册
│ ├── ai-recommend/ # [MODIFY] ai-recommend.wxml 2 处 + json 注册
│ ├── outfit-detail/ # [MODIFY] outfit-detail.wxml 2 处 + json 注册
│ ├── outfit-edit/ # [MODIFY] outfit-edit.wxml 1 处 + json 注册
│ ├── outfit-share-detail/ # [MODIFY] outfit-share-detail.wxml 3 处 + json 注册
│ ├── closet-share/ # [MODIFY] closet-share.wxml 2 处(封面/头像)+ json 注册
│ ├── closet-share-create/ # [MODIFY] closet-share-create.wxml 1 处(头像)+ json 注册
│ ├── closet-share-detail/ # [MODIFY] closet-share-detail.wxml 2 处 + json 注册
│ ├── ai-pending/ # [MODIFY] ai-pending.wxml 1 处(封面格)+ json 注册
│ ├── profile/ # [MODIFY] profile.wxml 2 处(头像)+ json 注册
│ └── clothes-detail/ # [MODIFY] clothes-detail.wxml 1 处(详情大图)+ json 注册
```
不改动:pages/wardrobe(经 clothes-card 生效)、pages/upload(本地临时图预览)、damo 素材、custom-tab-bar、全局样式、config.ts、接口层与所有 .ts 业务逻辑。
## 关键代码结构
```ts
// miniprogram/utils/image-cache.ts —— 对外接口(仅签名)
/** 内存命中快速通道:命中返回本地路径,否则 null(供组件首帧零延迟显示) */
export function getCachedLocalPath(src: string): string | null
/** 解析图片:磁盘命中直接返回本地路径;未命中入队下载落盘后返回;失败 reject(调用方回退网络地址) */
export function resolveImage(src: string): Promise<string>
/** 手动清空图片缓存目录(预留维护入口) */
export function clearImageCache(): Promise<void>
```
组件属性约定:`src: String`;`mode: String = 'aspectFill'`;`lazyLoad: Boolean = false`(wxml 以 `lazy-load` 传入)。
## 实施注意(Implementation Notes)
- 占位/下载期间严禁渲染网络 URL(核心防漏点);仅下载失败回退。
- 缓存范围判定统一为「仅 https 开头的地址进入缓存流程」,天然兼容真机与开发者工具的本地路径(wxfile://、http://tmp、http://usr)。
- 文件系统操作全部用异步 API(access/mkdir/unlink/readdir/stat),仅内存 Map 同步;清扫异步执行且去重,避免主线程卡顿。
- downloadFile 并发 ≤4 且同 key 任务合并,防止列表页瞬时大量图片同时下载。
- 日志复用 `utils/logger.ts`:debug 记命中/miss/落盘与清扫摘要,warn 仅一次用于域名未配置熔断;不输出签名 URL 原文,只输出稳定 key 或哈希文件名,保护实时日志配额。
- 零新增依赖:双哈希自实现;TS strict(noUnusedLocals/noImplicitAny)下编写;注释保持项目现有中文风格。
- 需在微信公众平台「开发设置-服务器域名」配置 **downloadFile 合法域名**(图片 CDN 域名);开发者工具本地验证可勾选「不校验合法域名」。
## 验证与上线
1. 首次冷启动进入衣橱/AI 穿搭:先占位后出图,日志呈现 miss → download → saved。
2. 返回重进或杀进程重进:日志 hit(内存/磁盘),Network 面板无同图请求。
3. 列表刷新后签名参数变化:仍命中本地缓存(稳定 key 生效)。
4. 头像、拼图封面、详情大图、分享封面视觉零变化(圆角、aspectFill/Fit、lazy-load)。
5. 模拟域名未配置:自动熔断回退网络图,页面功能正常。
6. `npx tsc --noEmit`(或开发者工具编译)无类型错误,read_lints 无新增告警。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose: 在全部替换完成后,对 miniprogram 目录做一轮全仓交叉复核,确认所有网络图片使用点均已接入 cached-image、无遗漏或误改(含 wardrobe 经 clothes-card 的间接使用、upload 本地预览与 damo 包内素材未受影响)。
- Expected outcome: 输出完整的图片使用点核对清单与结论,明确 22 处替换全覆盖、无残留的网络 image 标签,作为验收依据。