330 lines
12 KiB
JavaScript
330 lines
12 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'
|
||
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) |