// frontend/utils/cacheManager.js // 缓存清理统一封装层(无缓存版) // 详见 docs/superpowers/specs/2026-07-28-cache-cleanup-design.md // // ★ task #40:去掉全部缓存机制 // - 每次 load() / 进详情页都强制重算(不读 cache) // - 清理后不再 deductFromCache,直接重算 // - 保留黑名单、大小格式化、handler 注册、清理函数 import { invalidateAll } from './preloadApi/core' import sandboxTmpHandler from './handlers/sandboxTmpHandler' import sandboxResidualHandler from './handlers/sandboxResidualHandler' 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', 'app_last_hide_time', 'mailbox_collapsed', ]) // 前缀匹配 const PROTECTED_PREFIX = [ 'daily_login_completed_', 'avatar_file_', 'temp_register_', ] 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 === 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(无缓存,每次都重算) ── /** * 列表页读取 — 每次都重算 * @param {boolean} force 参数保留但不再使用(无缓存可绕过) */ export async function getCacheInfo(force = false) { const HANDLER_TIMEOUT_MS = 4000 const categories = [] for (const h of handlers.values()) { try { const info = await Promise.race([ h.computeSize(), new Promise((_, reject) => setTimeout(() => reject(new Error(`handler ${h.id} timeout (${HANDLER_TIMEOUT_MS}ms)`)), HANDLER_TIMEOUT_MS) ), ]) categories.push({ id: h.id, label: h.label, description: h.description || '', sizeBytes: info?.sizeBytes ?? 0, sizeBytesFromStorage: info?.sizeBytesFromStorage, sizeBytesFromSandbox: info?.sizeBytesFromSandbox, keyCount: info?.keyCount ?? 0, warning: !!h.warning, stale: false, }) } catch (e) { console.warn(`[cacheManager] computeSize failed: ${h.id}`, e.message) categories.push({ 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 let deviceTotalBytes = 0, deviceFreeBytes = 0 try { const info = uni.getStorageInfoSync() currentSizeKB = info.currentSize || 0 limitSizeKB = info.limitSize || 0 const allKeys = info.keys || [] 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) } try { const dev = await getDeviceStorageInfo() 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) // appUsedBytes = 其他(uni 内部 + 黑名单外 storage keys)+ 全部缓存(业务 subdir 文件) // 分解: // - currentSizeKB * 1024:uni.getStorageInfoSync() 返回的 storage 字节(uni 内部 + 业务 key) // - sandboxBytes:getSandboxTotalSize() 返回的 4 根顶层文件字节 // - sandboxOnlyCategories:sandbox-tmp(业务 tmp/)+ sandbox-residual(share/avatar/canvas) // 它们的子目录文件不在 currentSizeKB 也不在 sandboxBytes(_statDir 不递归), // 必须单独加,否则"已用空间"会少 1-几十 MB // 注:draft / progress / preload / guide / others 是 storage key,已被 currentSizeKB 包含,不重复加 // ★ 2026-08-03 移除 sandboxOnlyBytes: // getSandboxTotalSize 现在走 native / 完整 data 目录扫描,已覆盖全部 doc 子目录 // (业务 tmp、share/avatar/canvas、databases、cache),再按分类加一遍会双计 // (排查 345.8MB vs 系统 413MB 时发现)。分类 sizes 仍展示在列表,但不进 appUsedBytes。 const appUsedBytes = currentSizeKB * 1024 + sandboxBytes // ★ 2026-08-03 诊断:appUsedBytes 构成,定位"已用空间 X MB"从哪来 console.info( `[cacheManager] appUsedBytes=${Math.round(appUsedBytes / 1048576)}MB = ` + `storage(${Math.round((currentSizeKB * 1024) / 1048576)}MB) + ` + `sandbox(${Math.round(sandboxBytes / 1048576)}MB); ` + `categories=[${categories.map((c) => `${c.id}:${Math.round(c.sizeBytes / 1048576)}MB`).join(', ')}]` ) 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 const deviceUsagePercent = deviceTotalBytes > 0 ? (appUsedBytes / deviceTotalBytes) * 100 : 0 return { totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent, deviceTotalBytes, deviceFreeBytes, deviceUsagePercent, othersBytes: blacklistBytes + sandboxBytes, categories, } } /** * 详情页读取 — 每次都重算 */ 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() 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 维度) */ 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 } }) } /** * 编程式清理所有(登出流程/测试用) */ 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` 同一性断言。 // 这里需要直接返回存储在 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 注册(模块加载时执行) ── registerCategory(sandboxTmpHandler) registerCategory(sandboxResidualHandler) registerCategory(progressHandler) registerCategory(othersHandler) registerCategory(preloadHandler) registerCategory(guideHandler) registerCategory(draftHandler)