// frontend/utils/cacheManager.js // 缓存清理统一封装层 // 详见 docs/superpowers/specs/2026-07-28-cache-cleanup-design.md import { invalidateAll } from './preloadApi/core' import sandboxTmpHandler from './handlers/sandboxTmpHandler' import progressHandler from './handlers/progressHandler' import othersHandler from './handlers/othersHandler' import preloadHandler from './handlers/preloadHandler' import guideHandler from './handlers/guideHandler' import draftHandler from './handlers/draftHandler' import { getSandboxTotalSize, getDeviceStorageInfo } from './ioPath' // ── 黑名单(§3.4.1)── // 全等匹配 const PROTECTED_EXACT = new Set([ 'access_token', 'user', 'star_id', 'login_mobile', 'cid', 'deviceFp', 'pending_scan_url', 'gallery_owner_id', 'needs_welcome', 'has_seen_welcome', 'is_new_user', 'liked_assets_exhibition', ]) // 前缀匹配 const PROTECTED_PREFIX = [ 'daily_login_completed_', 'avatar_file_', 'temp_register_', ] /** * 判断 key 是否在黑名单中 */ export function isProtectedKey(key) { if (typeof key !== 'string') return false if (PROTECTED_EXACT.has(key)) return true return PROTECTED_PREFIX.some((p) => key.startsWith(p)) } // ── 大小格式化(§6.2)── export function formatSize(bytes) { if (typeof bytes !== 'number' || bytes < 0) return '—' if (bytes < 1024) return '< 1 KB' const kb = bytes / 1024 if (kb < 1024) return `${kb.toFixed(1)} KB` const mb = kb / 1024 if (mb < 1024) return `${mb.toFixed(1)} MB` return `${(mb / 1024).toFixed(2)} GB` } // ── 内部状态 ── const handlers = new Map() // handlerId → handler const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise // ── 注册 API ── export function registerCategory(handler) { if (!handler?.id) throw new Error('[cacheManager] handler.id is required') handlers.set(handler.id, handler) } function getHandler(id) { const h = handlers.get(id) if (!h) throw new Error(`[cacheManager] unknown handler: ${id}`) return h } // ── 公共 API ── /** * 列表页读取(汇总 + 存储配额 + 其他 section 数据) */ export async function getCacheInfo() { // [并行 1] 所有 handler 的 computeSize const categories = await Promise.all( Array.from(handlers.values()).map(async (h) => { try { const info = await h.computeSize() return { id: h.id, label: h.label, description: h.description || '', sizeBytes: info?.sizeBytes ?? 0, keyCount: info?.keyCount ?? 0, warning: !!h.warning } } catch (e) { console.warn(`[cacheManager] computeSize failed: ${h.id}`, e.message) return { id: h.id, label: h.label, description: h.description || '', sizeBytes: -1, keyCount: 0, warning: !!h.warning, error: e.message } } }) ) // [串行] 配额 + 黑名单 + 沙盒 let currentSizeKB = 0, limitSizeKB = 0, sandboxBytes = 0, blacklistBytes = 0 try { const info = uni.getStorageInfoSync() currentSizeKB = info.currentSize || 0 limitSizeKB = info.limitSize || 0 const allKeys = info.keys || [] // 黑名单 key 大小 for (const k of allKeys) { if (isProtectedKey(k)) { try { const v = uni.getStorageSync(k) if (v != null) blacklistBytes += JSON.stringify(v).length } catch (e) { /* skip */ } } } } catch (e) { console.warn('[cacheManager] getStorageInfoSync failed:', e.message) } try { sandboxBytes = await getSandboxTotalSize() } catch (e) { console.warn('[cacheManager] getSandboxTotalSize failed:', e.message) } let deviceTotalKB = 0, deviceFreeKB = 0 try { const dev = await getDeviceStorageInfo() deviceTotalKB = dev.totalBytes || 0 deviceFreeKB = dev.freeBytes || 0 // 调试日志:把 getDeviceStorageInfo 的原始返回和 cacheManager 解析后的值都打出来 console.log('[cacheManager][debug] getDeviceStorageInfo raw =', JSON.stringify(dev), 'parsed totalKB =', deviceTotalKB, 'freeKB =', deviceFreeKB) } catch (e) { console.warn('[cacheManager] getDeviceStorageInfo failed:', e.message) } const totalBytes = categories.reduce((sum, c) => sum + (c.sizeBytes > 0 ? c.sizeBytes : 0), 0) const appUsedBytes = currentSizeKB * 1024 + sandboxBytes const quotaTotalBytes = limitSizeKB * 1024 const raw = quotaTotalBytes - appUsedBytes const quotaAvailableBytes = Math.max(0, raw) const quotaExceeded = raw < 0 const usagePercent = quotaTotalBytes > 0 ? (appUsedBytes / quotaTotalBytes) * 100 : 0 // 设备级数据:plus.io.getStorageInfo 返回 KB,转字节 const deviceTotalBytes = deviceTotalKB * 1024 const deviceFreeBytes = deviceFreeKB * 1024 const deviceUsagePercent = deviceTotalBytes > 0 ? (appUsedBytes / deviceTotalBytes) * 100 : 0 return { totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent, deviceTotalBytes, deviceFreeBytes, deviceUsagePercent, othersBytes: blacklistBytes + sandboxBytes, categories, } } /** * 详情页读取(分组详情;简单 handler 返回 null) */ export async function getCategoryBreakdown(id) { const h = getHandler(id) if (typeof h.computeBreakdown !== 'function') return null try { return await h.computeBreakdown() } catch (e) { console.warn(`[cacheManager] computeBreakdown failed: ${id}`, e.message) return [] } } /** * 简单页清理(无分组维度) */ export function cleanCategory(id) { return _runWithInFlight(`${id}`, async () => { const h = getHandler(id) if (typeof h.clean !== 'function') { throw new Error(`[cacheManager] handler ${id} has no clean() (use cleanCategoryGroup)`) } try { const result = await h.clean() // preload 清理同步清内存(§3.4.4) if (id === 'preload') { try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) } } return result } catch (e) { console.warn(`[cacheManager] clean failed: ${id}`, e.message) throw e } }) } /** * 分组页清理(按 uid 维度) * @param {string} id * @param {object} opts * @param {string|null} opts.uid 'self' = 当前用户,null = 其他用户聚合,其他 = 具体 uid 字符串 */ export function cleanCategoryGroup(id, opts = {}) { const uidKey = opts.uid === undefined ? 'self' : String(opts.uid) return _runWithInFlight(`${id}#${uidKey}`, async () => { const h = getHandler(id) if (typeof h.cleanGroup !== 'function') { throw new Error(`[cacheManager] handler ${id} has no cleanGroup() (use cleanCategory)`) } try { const result = await h.cleanGroup({ uid: opts.uid === undefined ? 'self' : opts.uid }) if (id === 'preload') { try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) } } return result } catch (e) { console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message) throw e } }) } /** * 编程式清理所有(UI 不调用;登出流程/测试用) * 对每个 handler 调用其支持的清理方法: * - 优先 clean()(简单型) * - 否则调 cleanGroup({ uid: 'self' })(仅清当前用户的分组数据,符合"登出前清自己"的语义) */ export async function cleanAll() { const results = [] for (const h of handlers.values()) { try { let r if (typeof h.clean === 'function') { r = await h.clean() } else if (typeof h.cleanGroup === 'function') { r = await h.cleanGroup({ uid: 'self' }) } else { continue } results.push({ id: h.id, ...r, error: null }) } catch (e) { console.warn(`[cacheManager] cleanAll failed for ${h.id}:`, e.message) results.push({ id: h.id, freedBytes: 0, keyCount: 0, error: e.message }) } } try { invalidateAll() } catch (e) { console.warn('[cacheManager] cleanAll invalidateAll failed:', e.message) } return { freedBytes: results.reduce((s, r) => s + (r.freedBytes || 0), 0), perCategory: results } } // NOTE: 必须非 async —— async 会把返回的 Promise 再包一层,破坏 // `p1 === p2` 同一性断言(参见 plan §Task 12 Step 2 自测)。 // 这里需要直接返回存储在 cleanInFlight 中的 Promise 引用本身。 function _runWithInFlight(key, fn) { if (cleanInFlight.has(key)) return cleanInFlight.get(key) const p = (async () => { try { return await fn() } finally { cleanInFlight.delete(key) } })() cleanInFlight.set(key, p) return p } // ── Handler 注册(模块加载时执行;新增 handler 在此追加 registerCategory 调用)── registerCategory(sandboxTmpHandler) registerCategory(progressHandler) registerCategory(othersHandler) registerCategory(preloadHandler) registerCategory(guideHandler) registerCategory(draftHandler)