279 lines
9.6 KiB
JavaScript
279 lines
9.6 KiB
JavaScript
// 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 包含,不重复加
|
||
const SANDBOX_ONLY_CATEGORY_IDS = new Set(['sandbox-tmp', 'sandbox-residual'])
|
||
const sandboxOnlyBytes = categories
|
||
.filter((c) => SANDBOX_ONLY_CATEGORY_IDS.has(c.id) && c.sizeBytes > 0)
|
||
.reduce((sum, c) => sum + c.sizeBytes, 0)
|
||
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes + sandboxOnlyBytes
|
||
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)
|