topfans/frontend/utils/cacheManager.js

330 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)