// 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 // ── 内存缓存:避免每次进 cache-cleanup 页都走 4 沙盒根遍历(最坏 ~2s) ── // 5min TTL: // - 默认命中返回缓存(瞬时显示) // - 清理动作完成后调 deductFromCache() 局部扣减(不清空),下次返回列表仍命中 // - invalidateCache() 仅用于特殊场景(如调试 / 用户主动重置) const CACHE_TTL_MS = 5 * 60 * 1000 let _cachedInfo = null let _cachedAt = 0 /** 清理缓存:清理动作完成后必须调用,下次 getCacheInfo 必重算 */ export function invalidateCache() { _cachedInfo = null _cachedAt = 0 } /** * 清理后局部扣减缓存(不重算): * - 对应 category 的 sizeBytes / keyCount 扣减 * - totalBytes / appUsedBytes 同步扣减 * - usagePercent / deviceUsagePercent 重新计算 * 不动 othersBytes(黑名单 + sandboxBytes 不直接减少;5min 内下次进页面会被 force=下拉刷新校正) * * 关键:必须创建新对象赋给 _cachedInfo,而不是 in-place 修改属性 * 否则 cache-cleanup.vue 里 `info.value = cached` 检测到引用未变(Object.is 相等), * 不会触发 Vue 响应式更新 → 对应 category 的 sizeBytes 显示仍是旧值 */ function deductFromCache(id, freedBytes, freedKeyCount) { if (!_cachedInfo || !Array.isArray(_cachedInfo.categories)) return const newCategories = _cachedInfo.categories.map((c) => c.id === id ? { ...c, sizeBytes: Math.max(0, (c.sizeBytes || 0) - freedBytes), keyCount: Math.max(0, (c.keyCount || 0) - (freedKeyCount || 0)), } : c ) const newAppUsedBytes = Math.max(0, (_cachedInfo.appUsedBytes || 0) - freedBytes) _cachedInfo = { ..._cachedInfo, categories: newCategories, totalBytes: Math.max(0, (_cachedInfo.totalBytes || 0) - freedBytes), appUsedBytes: newAppUsedBytes, usagePercent: _cachedInfo.quotaTotalBytes > 0 ? (newAppUsedBytes / _cachedInfo.quotaTotalBytes) * 100 : 0, deviceUsagePercent: _cachedInfo.deviceTotalBytes > 0 ? (newAppUsedBytes / _cachedInfo.deviceTotalBytes) * 100 : 0, } _cachedAt = Date.now() } /** * 同步检查缓存:命中且未过期 → 直接返回缓存对象(不走 spinner) * 否则返回 null(需要走 getCacheInfo 异步计算) */ export function peekCache() { if (_cachedInfo && Date.now() - _cachedAt < CACHE_TTL_MS) { return _cachedInfo } return null } // ── 注册 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 数据) * @param {boolean} force 强制重算(跳过 30s 缓存);下拉刷新/清理后用 */ export async function getCacheInfo(force = false) { // 缓存命中:30s 内且不强制刷新 → 同步返回,避免重复走沙盒遍历 const now = Date.now() if (!force && _cachedInfo && now - _cachedAt < CACHE_TTL_MS) { return _cachedInfo } // 缓存未命中或过期或强制刷新 → 重新计算 // [并行 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 deviceTotalBytes = 0, deviceFreeBytes = 0 try { const dev = await getDeviceStorageInfo() // ioPath.getDeviceStorageInfo 返回的 totalBytes/freeBytes 已是字节(Android: blockSize*blocks, iOS: NSFileSystemSize),不要再 * 1024 deviceTotalBytes = dev.totalBytes || 0 deviceFreeBytes = dev.freeBytes || 0 } 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 // 设备级数据已在 try 块里取好(ioPath 返回的就是字节,不再 * 1024) const deviceUsagePercent = deviceTotalBytes > 0 ? (appUsedBytes / deviceTotalBytes) * 100 : 0 const result = { totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent, deviceTotalBytes, deviceFreeBytes, deviceUsagePercent, othersBytes: blacklistBytes + sandboxBytes, categories, } // 写入缓存(即便部分字段失败/降级也缓存,避免反复重算;清理动作会主动 invalidate) _cachedInfo = result _cachedAt = Date.now() return result } /** * 详情页读取(分组详情;简单 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) } } // 局部扣减缓存(不清空):返回列表页时 cache-cleanup 还能命中缓存,瞬时显示新数据 const { freedBytes = 0, keyCount = 0 } = result || {} deductFromCache(id, freedBytes, keyCount) 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) } } // 局部扣减缓存(不清空) const { freedBytes = 0, keyCount = 0 } = result || {} deductFromCache(id, freedBytes, keyCount) 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)