// 图片本地缓存:网络图片首次展示时下载落盘,后续直接复用本地文件,减少 CDN 重复流量。 // 背景:接口返回的图片是带签名/时间戳的临时 URL,每次请求都会变化;微信 image 自带的 // 缓存按「完整 URL(含 query)」记录,签名一变即失效,同一张图会被反复下载。 // 本模块按「剥离签名参数后的稳定地址」作为缓存 key 落盘,同一张图只请求 CDN 一次。 // // 命中链路:内存 Map → 磁盘文件 → downloadFile 落盘(并发 ≤4、同 key 任务去重)。 // 失败处理:单图失败由组件回退网络地址;检测到 downloadFile 域名未配置时整会话熔断, // 避免继续发起必然失败的请求。 // 空间治理:TTL + 容量上限,空闲时异步清扫,不阻塞渲染。 import { logger } from './logger' // 日志模块名 const LOG = 'image-cache' // 缓存目录(本地用户文件,随小程序代码包生命周期清理) const CACHE_DIR = `${wx.env.USER_DATA_PATH}/imgcache` // 过期时间:超过 7 天未修改的缓存文件优先淘汰 const TTL_MS = 7 * 24 * 60 * 60 * 1000 // 目录容量上限:本地文件总量上限约 200MB,自设 80MB 留足业务余量 const MAX_BYTES = 80 * 1024 * 1024 // 文件数量上限 const MAX_FILES = 300 // 内存映射上限,超出时整体重建(后续重新走磁盘命中,代价可控) const MAX_MEM_ENTRIES = 500 // downloadFile 并发上限(微信侧总上限 10,留余量给业务请求) const MAX_CONCURRENCY = 4 // 清扫延迟:首次解析后延迟执行,避免与首屏图片加载抢资源 const PRUNE_DELAY_MS = 5000 // 运行期每成功落盘 50 张再触发一次清扫 const PRUNE_EVERY_SAVES = 50 // 签名类参数名(小写,集中维护):签名 URL 每次返回都不同,必须从缓存 key 中剥离,否则永远无法命中 const SIGN_PARAMS = new Set([ // 通用签名 / 时间戳 'sign', 'signature', 'sig', 't', 'ts', 'timestamp', 'time', 'expires', 'expire', 'auth', 'token', 'nonce', // 腾讯云 COS 'authorize', 'x-cos-security-token', 'q-sign-algorithm', 'q-sign-time', 'q-key-time', 'q-signature', 'q-header-list', 'q-url-param-list', // 阿里云 OSS 'ossaccesskeyid', 'security-token', 'auth_key', // AWS S3 / CloudFront 'x-signature', 'x-amz-algorithm', 'x-amz-credential', 'x-amz-date', 'x-amz-expires', 'x-amz-signature', 'x-amz-security-token', 'x-amz-signedheaders', 'key-pair-id', 'policy', ]) // ===== 运行状态 ===== // 稳定 key → 本地文件路径(内存快速通道) const memCache = new Map() // 稳定 key → 进行中的下载任务(并发复用,避免同一张图重复下载) const inflight = new Map>() // 下载域名未配置等不可恢复失败的会话级熔断 let disabled = false // 缓存目录就绪状态与初始化 Promise let dirReady = false let dirPromise: Promise | null = null // downloadFile 并发闸门 let running = 0 const waiters: Array<() => void> = [] // 清扫节流状态 let firstPruneDone = false let pruneScheduled = false let savesSincePrune = 0 function fs(): WechatMiniprogram.FileSystemManager { return wx.getFileSystemManager() } // 仅 https 网络图进入缓存流程;本地路径(wxfile:// / http://tmp / http://usr / 包内路径)原样返回 function isRemote(src: string): boolean { return src.indexOf('https://') === 0 } // ===== 稳定 key 与文件指纹 ===== // 剥离签名/时间戳参数,其余 query 排序后与 host+path 拼接;同一张图签名变化也命中同一缓存 function stableKey(src: string): string { const idx = src.indexOf('?') if (idx < 0) return src const base = src.slice(0, idx) const kept: string[] = [] for (const part of src.slice(idx + 1).split('&')) { if (!part) continue const eq = part.indexOf('=') const rawKey = (eq >= 0 ? part.slice(0, eq) : part).toLowerCase() let name = rawKey try { name = decodeURIComponent(rawKey) } catch (e) { // 参数名解码失败按原样处理 } if (SIGN_PARAMS.has(name)) continue kept.push(part) } if (!kept.length) return base kept.sort() return `${base}?${kept.join('&')}` } function hex32(n: number): string { return ('00000000' + (n >>> 0).toString(16)).slice(-8) } // FNV-1a 32 位:不引入依赖的快速哈希 function fnv1a(str: string): string { let h = 0x811c9dc5 for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i) h = Math.imul(h, 0x01000193) >>> 0 } return hex32(h) } // djb2 32 位:与 FNV-1a 拼接为 16 位十六进制文件名,降低碰撞概率 function djb2(str: string): string { let h = 5381 for (let i = 0; i < str.length; i++) { h = (Math.imul(h, 33) + str.charCodeAt(i)) >>> 0 } return hex32(h) } function hashKey(key: string): string { return fnv1a(key) + djb2(key) } // 扩展名取自 URL 路径,白名单外统一按 .jpg 处理(仅影响本地文件名,不影响渲染) function extOf(src: string): string { const path = src.split('?')[0].split('#')[0] const m = /\.(jpg|jpeg|png|webp|gif)$/i.exec(path) if (!m) return '.jpg' const ext = m[1].toLowerCase() return ext === 'jpeg' ? '.jpg' : `.${ext}` } function localPathOf(key: string, src: string): string { return `${CACHE_DIR}/${hashKey(key)}${extOf(src)}` } // ===== 文件系统操作(全部异步包装,失败静默由调用方决定兜底) ===== function ensureDir(): Promise { if (dirReady) return Promise.resolve() if (!dirPromise) { dirPromise = new Promise((resolve) => { fs().mkdir({ dirPath: CACHE_DIR, recursive: true, success: () => resolve(), // 目录已存在等失败一律放行,落盘失败会在下载阶段回退 fail: () => resolve(), }) }).then(() => { dirReady = true }) } return dirPromise } function accessFile(path: string): Promise { return new Promise((resolve) => { fs().access({ path, success: () => resolve(true), fail: () => resolve(false), }) }) } function unlinkQuiet(path: string): Promise { return new Promise((resolve) => { fs().unlink({ filePath: path, success: () => resolve(), fail: () => resolve(), }) }) } function readdir(): Promise { return new Promise((resolve, reject) => { fs().readdir({ dirPath: CACHE_DIR, success: (res) => resolve(res.files || []), fail: (err) => reject(new Error((err && err.errMsg) || 'readdir fail')), }) }) } function statFile(path: string): Promise<{ size: number; mtime: number } | null> { return new Promise((resolve) => { fs().stat({ path, success: (res) => { const s = res.stats if (typeof s.isFile === 'function' && !s.isFile()) { resolve(null) return } // 官方单位是秒,但个别端返回毫秒,统一归一化为毫秒 const raw = Number(s.lastModifiedTime) || 0 const mtime = raw > 1e11 ? raw : raw * 1000 resolve({ size: Number(s.size) || 0, mtime }) }, fail: () => resolve(null), }) }) } // ===== 下载:并发闸门 + 直写目标文件 ===== function acquireSlot(): Promise { if (running < MAX_CONCURRENCY) { running++ return Promise.resolve() } return new Promise((resolve) => { waiters.push(() => { running++ resolve() }) }) } function releaseSlot(): void { running-- const next = waiters.shift() if (next) next() } function download(url: string, filePath: string): Promise { return new Promise((resolve, reject) => { wx.downloadFile({ url, filePath, success: (res) => { if (res.statusCode === 200) resolve() else reject(new Error(`http ${res.statusCode}`)) }, fail: (err) => reject(new Error((err && err.errMsg) || 'download fail')), }) }) } // ===== 空闲清扫:TTL 过期 + 超容量/超数量按最旧优先删除 ===== async function pruneCache(): Promise { try { const names = await readdir() const now = Date.now() const alive: Array<{ path: string; size: number; mtime: number }> = [] let expired = 0 for (const name of names) { const path = `${CACHE_DIR}/${name}` const st = await statFile(path) if (!st) continue if (now - st.mtime > TTL_MS) { await unlinkQuiet(path) expired++ continue } alive.push({ path, size: st.size, mtime: st.mtime }) } alive.sort((a, b) => a.mtime - b.mtime) let bytes = 0 for (const it of alive) bytes += it.size let removed = 0 let i = 0 while (i < alive.length && (bytes > MAX_BYTES || alive.length - i > MAX_FILES)) { await unlinkQuiet(alive[i].path) bytes -= alive[i].size removed++ i++ } logger.debug(LOG, 'prune done, total=', names.length, 'expired=', expired, 'overflow=', removed) } catch (e) { // 目录尚未创建等情况直接跳过,不重试不报错 logger.debug(LOG, 'prune skipped') } } function notePrune(): void { // 首次解析后延迟清扫一次 if (!firstPruneDone && !pruneScheduled) { pruneScheduled = true setTimeout(runPrune, PRUNE_DELAY_MS) } } function noteSaved(): void { savesSincePrune++ // 运行期累计落盘后再触发一次 if (firstPruneDone && !pruneScheduled && savesSincePrune >= PRUNE_EVERY_SAVES) { pruneScheduled = true setTimeout(runPrune, PRUNE_DELAY_MS) } } function runPrune(): void { pruneCache().then(() => { firstPruneDone = true savesSincePrune = 0 pruneScheduled = false }) } // ===== 对外接口 ===== function memSet(key: string, localPath: string): void { if (memCache.size >= MAX_MEM_ENTRIES) memCache.clear() memCache.set(key, localPath) } /** 内存命中快速通道:命中返回本地路径,否则 null(供组件首帧零延迟直出) */ export function getCachedLocalPath(src: string): string | null { if (!src || !isRemote(src) || disabled) return null return memCache.get(stableKey(src)) || null } async function loadOrDownload(key: string, src: string): Promise { const localPath = localPathOf(key, src) const id = hashKey(key) if (await accessFile(localPath)) { logger.debug(LOG, 'hit disk', id) return localPath } await ensureDir() await acquireSlot() try { await download(src, localPath) noteSaved() logger.debug(LOG, 'saved', id) return localPath } catch (e) { // 清理可能残留的半截文件,交由组件回退网络地址 await unlinkQuiet(localPath) const msg = e instanceof Error ? e.message : String(e) if (!disabled && /not in domain list/i.test(msg)) { disabled = true logger.warn(LOG, 'downloadFile 域名未配置,停用图片缓存并回退网络图(请在小程序后台配置 downloadFile 合法域名)') } else { logger.debug(LOG, 'download fail, fallback network, id=', id, 'reason=', msg) } throw e } finally { releaseSlot() } } /** 解析图片:磁盘命中直接返回本地路径;未命中入队下载落盘后返回;失败 reject(调用方回退网络地址) */ export function resolveImage(src: string): Promise { if (!src || !isRemote(src)) return Promise.resolve(src) if (disabled) return Promise.reject(new Error('image cache disabled')) const key = stableKey(src) const hit = memCache.get(key) if (hit) { logger.debug(LOG, 'hit mem', hashKey(key)) return Promise.resolve(hit) } const pending = inflight.get(key) if (pending) return pending notePrune() const task = loadOrDownload(key, src).then( (localPath) => { memSet(key, localPath) inflight.delete(key) return localPath }, (err) => { inflight.delete(key) throw err } ) inflight.set(key, task) return task } /** 删除某个网络图对应的本地缓存(组件自愈用:本地文件损坏时清理后回退网络地址) */ export function dropImageCache(src: string): void { if (!src || !isRemote(src)) return const key = stableKey(src) const localPath = memCache.get(key) || localPathOf(key, src) memCache.delete(key) unlinkQuiet(localPath).then(() => undefined) } /** 清空全部图片缓存(预留维护入口) */ export function clearImageCache(): Promise { memCache.clear() return readdir() .then((names) => Promise.all(names.map((name) => unlinkQuiet(`${CACHE_DIR}/${name}`)))) .then(() => { logger.debug(LOG, 'clear done') }) .catch(() => undefined) }