topfans/frontend/utils/cacheManager.js
zheng020 bd3296421a fix:修复存储空间已用大小虚高(对齐系统存储口径)
- cacheManager:移除 sandboxOnlyBytes,避免与 getSandboxTotalSize 完整 data 目录扫描双计(345.8MB vs 系统 413MB 根因)
- ioPath:StorageStatsManager 改 getApkBytes+getDataBytes+getCacheBytes 三部分求和,规避 proxy 把 getAppBytes 误解析成仅 base.apk(21MB)
- ioPath:native 返回值 <30MB 判不可信 → 完整 data 目录扫描降级(_scanFullAppUsage)
- ioPath:4 根扫描 PRIVATE_DOC 只扫顶层(防双计)、PRIVATE_WWW 递归数全;新增 _sumDirSizeRecursive 带 phantom entry 过滤/路径去重/深度与文件数上限

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:28:13 +08:00

286 lines
10 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
//
// ★ 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 * 1024uni.getStorageInfoSync() 返回的 storage 字节uni 内部 + 业务 key
// - sandboxBytesgetSandboxTotalSize() 返回的 4 根顶层文件字节
// - sandboxOnlyCategoriessandbox-tmp业务 tmp/+ sandbox-residualshare/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)