233 lines
7.9 KiB
JavaScript
233 lines
7.9 KiB
JavaScript
// 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'
|
||
|
||
// ── 黑名单(§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 {
|
||
const { getSandboxTotalSize } = await import('./ioPath')
|
||
sandboxBytes = await getSandboxTotalSize()
|
||
} catch (e) {
|
||
console.warn('[cacheManager] getSandboxTotalSize 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
|
||
|
||
return {
|
||
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent,
|
||
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) |