fix:修复存储空间bug

This commit is contained in:
zheng020 2026-08-03 15:42:33 +08:00
parent d9e3ff9eae
commit 9e02001496
13 changed files with 3158 additions and 702 deletions

View File

@ -39,11 +39,13 @@
- 清理"其他用户的草稿"需要单独更强的警告(避免误操作),而清理"自己的草稿"只需要普通警告
- 其他分类preload / progress / sandbox-tmp / others详情页可简化一组 + 单按钮)
3. **永远不清的 key 黑名单**——登录态(`access_token`/`user`/`star_id`/`login_mobile`/`cid`/`deviceFp`/`pending_scan_url`/`gallery_owner_id`)、设备态(`needs_welcome`/`has_seen_welcome`/`is_new_user`/`daily_login_completed_*`)、用户行为(`liked_assets_exhibition`)、外部资源(`avatar_file_*` 元数据)、注册中状态(`temp_register_*` 含明文密码)**五类** key 由 manager 内置黑名单拦截,**任何清理路径都不会触碰**。详见 §3.4.1。
4. **范围不包含头像/图片缓存**——`avatarCache.js` 的 `avatar_file_*` storage key 与 `uni.saveFile``savedFilePath` 均纳入黑名单§3.4.1)。原因:用户误清会导致头像全部重新下载,体验差;且与本设计目标(清理临时性业务缓存)不符。**MVP 不纳入清理,仅作黑名单保护**
4. **头像图片文件纳入清理,元数据 key 仍受保护**2026-07-31 修订,原 MVP 决策为"完全不纳入")——`avatarCache.js` 的 `avatar_file_*` storage key **仍在黑名单**§3.4.1,永不删);但 `uni.saveFile` 落在 `_doc/uniapp_save/` 的头像**图片文件**改由 `sandbox-residual` handler 清理。修订理由:该目录按 URL hash 一张张累积、**无 TTL、无上限**,且 `clearAllAvatarCache()` 全项目零调用,实际是只增不减的沙盒占用;用户能在"已用空间"里看到它,却没有任何入口清理。误清代价可控——`getCachedAvatarPath` 在文件缺失时会自愈(`getFileInfo` 失败 → 删记录 → 返回 null → 重新下载)
5. **范围包含创作草稿(带警告)**——草稿 key 改造§11 迁移项)后形式为 `*_${currentUid}`。**所有**草稿 key无论当前 uid / 其他 uid / legacy 无后缀)**统一归 `draft` handler**,通过 `computeBreakdown()` 按 uid 分组展示;强警告只在清理"其他用户/legacy"组时触发。UI ConfirmModal 在清理自己草稿时弹普通警告(`handler.warning = true`),清理他人时弹强警告(`handler.strongWarning = true`)。**不**走 `others` handler 兜底,避免双重计入 totalBytes 且强警告无法生效。
6. **preload handler 清所有用户**——`preload:${oldUid}:*` 是跨账号的真正垃圾。spec 的 `preload.clean()` 通过遍历所有 `preload:` 前缀 key 一次性删,不依赖 currentUid。
7. **cleanAll 同步调 `invalidateAll()`**——`preloadApi/core.js` 的 `memoryMap` 是进程级共享,切账号不清会残留老用户数据。`cacheManager.cleanAll()` 在 storage/sandbox 清理完成后调 `core.invalidateAll()` 清空整个内存层(含 `inFlightMap`)。
8. **草稿 key 改造为 uid 绑定(新增迁移项)**——将 `castlove_form_data` 等草稿 key 改为 `castlove_form_data_${currentUid}` 等 uid 后缀形式(详见 §11 迁移项),账号切换后看不到对方草稿(隐私 + 体验)。**所有**草稿 key无论 currentUid / 其他 uid / legacy 无后缀)**统一归 `draft` handler**,通过 `computeBreakdown()` 按 uid 分组展示;强警告只在清理"其他用户/legacy"组时触发。**不**走 `others` 兜底(避免双重计入 totalBytes 且强警告无法生效)。
9. **App.vue 启动期不做清理**2026-07-31 决议)——原 `onLaunch``clearAllSandboxTmpFiles()` + `cleanupUpgradePackages()` 两处启动清理**全部移除**。理由:项目已有"存储空间"分类页承担所有清理职责,启动期隐性清理会让用户对"快用 0MB"产生不切实际的预期。**取舍**:从不进存储空间页面的用户会持续累积 `_doc/<业务>/tmp/` 旧版本残留和未安装的升级包;这是用户主动选择的产品决策,不在反对范围。**保留**`uni-upgrade-center-app` 的 `cleanupAfterInstall`(安装成功自动清)和 `checkLocalStoragePackage`(版本不符自动删旧包)仍生效。
10. **sandbox-residual 改为分组型 handler**2026-07-31 修订)——原 MVP 决策为简单型(单按钮),修订为按 `share/<uid>/` 子目录分组("我的" / "其他用户" / "全局"),与 `draft` / `guide` 一致。写入侧(`useShare.js#copyToSandbox`)改为 `_doc/share/<uid>/` 路径uid 取自 `getStoredUser()?.uid ?? 'guest'`。头像缓存、canvas 合成图、copyStaticToDoc 复制件、遗留 `preload/` 等归"全局"组(头像按 URL hash 去重是设计本意,按 uid 分会造重复存储)。
### 核心架构图TL;DR
@ -143,7 +145,8 @@ preloadApi)
| `draft` | "创作中的草稿" | "未提交的创作表单/生成结果" | **true** | 草稿 key 改造§11`*_${currentUid}` 后缀形式。**实现方式**handler 内部维护一个 7 个 base key 的静态数组(`['castlove_form_data', 'CASTLOVE_FORM_KEY', 'temp_nft_data', 'GENERATED_IMAGES_KEY', 'GENERATION_RESULT_META_KEY', 'LENTICULAR_STUDIO_STORAGE_KEY', 'CRAFT_SELECTED_IMAGE_KEY']`),运行时取 `currentUid` 拼成 `baseKey_${currentUid}`,遍历 `uni.getStorageInfoSync` 找匹配项。新 key 形式:`castlove_form_data_${currentUid}` / `CASTLOVE_FORM_KEY_${currentUid}` / ...(同上) |
| `progress` | "活动进度缓存" | "支持活动页断网浏览" | false | 全部 `progress_${activityId}` key实现方式遍历 `uni.getStorageInfoSync` 中所有以 `progress_` 开头的 key 全部删除) |
| `guide` | "引导记录" | "新手引导完成标记" | false | `guide_*` 系列(仅清非当前用户/会话残留;详见 §3.4 |
| `sandbox-tmp` | "临时文件" | "上传/分享过程产生的临时文件" | false | `clearAllSandboxTmpFiles`(保留 `preload/share/image` 白名单) |
| `sandbox-tmp` | "临时文件" | "上传/分享过程产生的临时文件" | false | `clearAllSandboxTmpFiles``<业务目录>/tmp/**`,保留 `preload/share/image` 白名单) |
| `sandbox-residual` | "分享图与头像缓存" | "分享保存的图片、合成图与头像缓存文件" | false | 2026-07-31 新增并改为分组型)`clearSandboxResidualGlobal` / `clearSandboxShareByUid`——doc **根一层**散落图片 + `uniapp_save/`(头像缓存,保留 .wgt/.apk/.ipa 升级包)+ `uniapp_temp_*/`canvas 合成图)+ 遗留 `preload/`;并 `share/<uid>/` 按账号分目录,分组清理由 handler 完成。详见 §3.4.3 |
| `others` | "其他业务缓存" | "未归类的少量业务数据" | false | 兜底分类:未匹配上述任一规则**且不在黑名单**的 key 汇总。**不**包含草稿 keys任何 uid 后缀 + legacy那些全部归 `draft` handler。包含需保护的工作流关键 key`generation_flow_payload`、`__package_info__` 等)的**白名单排除**——这些 key 即使不在黑名单也不归 others 清理(避免破坏进行中的生成流程 / 升级包)。 |
### 3.3 cacheManager 公共 API
@ -229,6 +232,24 @@ await cacheManager.cleanAll()
**关键**§5.1 的 `getCacheInfo()` 必须先过滤黑名单 key再分类计算。否则黑名单 key 既不会展示也不会被删,但会被错误计入 totalBytes 误导用户。
**沙盒文件的分区规则**2026-07-31 新增,与上面的 storage key 分区相互独立):
沙盒 doc 目录下的文件由两个 handler 瓜分,**两者目标集合必须不相交**,否则同一份字节被双算、`totalBytes` 虚高:
| Handler | 覆盖范围 | 实现 |
|---|---|---|
| `sandbox-tmp` | `<业务目录>/tmp/**`(二层起),跳过 `PROTECTED_SUBDIRS = {preload, share, image}` | `ioPath.js#scanSandboxTmpFiles` / `clearAllSandboxTmpFiles` |
| `sandbox-residual` | doc **根一层**:图片扩展名的散落文件 + `uniapp_save/` + `uniapp_temp_*/` + `preload/` | `ioPath.js#scanSandboxResidualFiles` / `clearSandboxResidualGlobal` |
约束:
- `sandbox-residual` **刻意不覆盖** doc 根下的非图片文件uni 内部 `.db` / `.json` 等,误删后果未知)、业务目录(归 `sandbox-tmp`)、`share/` 与 `image/`(保持原白名单语义)
- 二者的判定各自独立维护:`sandbox-tmp` 用 `PROTECTED_SUBDIRS``sandbox-residual` 用 `_residualKind()`。**不要**把 `sandbox-residual` 的目标塞进 `PROTECTED_SUBDIRS`——那份白名单同时被登出全清 `clearAllSandboxTmpDirs` 与启动清理 `clearAllSandboxTmpFiles` 消费,改动爆炸半径过大
- `_residualKind()` 被扫描 / 计数 / 清理三处共用,避免出现"算得到但删不掉"的口径漂移。它把目标分成三类:
- `file` — 根级散落图片文件,直接删
- `dir-purge` — 整目录递归清空(`preload/`、`uniapp_temp_*/`
- `dir-filtered` — 目录保留、逐文件删,跳过 `RESIDUAL_KEEP_EXT``.wgt/.apk/.ipa`)。目前只有 `uniapp_save/`:它是 `uni.saveFile` 的**公共**落点,除头像外还存放 uni-upgrade-center 的升级包,而 `upgrade-popup#checkLocalStoragePackage` **不校验文件是否存在**,整目录删会让它拿着失效的 `UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH` 记录提示"可直接安装",安装必失败
- 沙盒型 handler 必须登记进 `cacheManager.js#SANDBOX_BACKED_IDS`,否则清理后 `deductFromCache` 不扣减 `othersBytes`"其他"行会显示旧值
#### 3.4.4 内存层清理(详情页 + cleanAll 同步触发)
`preloadApi/core.js``memoryMap` 是进程级单例 Map —— **所有用户的 preload 数据都在同一个 Map 里**。切账号不清会导致老用户条目挤占内存。
@ -575,6 +596,7 @@ mock `uni.getStorageSync` / `clearAllSandboxTmpFiles` / `core.invalidateAll`
| `frontend/utils/handlers/progressHandler.js` | 进度缓存 handler简单型 |
| `frontend/utils/handlers/guideHandler.js` | 引导记录 handler分组型含 computeBreakdown + cleanGroup |
| `frontend/utils/handlers/sandboxTmpHandler.js` | 沙盒临时文件 handler基于 ioPath.js简单型 |
| `frontend/utils/handlers/sandboxResidualHandler.js` | 沙盒 doc 根残留 handler基于 ioPath.js简单型2026-07-31 新增) |
| `frontend/utils/handlers/othersHandler.js` | 兜底分类 handler简单型 |
| `frontend/pages/profile/cache-cleanup.vue` | 缓存清理列表页(只展示,无按钮) |
| `frontend/pages/profile/cache-cleanup-detail.vue` | 缓存清理详情页(按 `id` 分发到简单/分组模板) |

View File

@ -0,0 +1,674 @@
# 缓存清理超时问题修复方案2026-07-31
> 配套前置分析:本文基于 `docs/specs/2026-07-28-cache-cleanup-design.md` 的 MVP 设计,针对生产中发现的「**存储空间页面超时**」问题进行根因定位与修复方案。
>
> 适用版本:`feat/uni` 分支未提交修改cacheManager.js / ioPath.js / sandboxResidualHandler.js / sandboxTmpHandler.js / othersHandler.js / useShare.js / image-compositor.js / cache-cleanup*.vue
---
## 一、方案概述(必读)
### 要解决的问题
**业务问题**
- 用户进入"我的 → 存储空间"页面,**列表页加载超时**10s 兜底被触发),用户看到「加载失败,点此重试」。
- 进入「分享图与头像缓存」详情页时,**详情页加载超时**8s 兜底被触发UI 显示「加载失败,但可尝试清理」的合成全局组。
- 旧 `sandbox-tmp`临时文件handler 工作正常(白名单扫描,~5 个目录,< 500ms 完成**新加的 `sandbox-residual`**分享图/头像/canvas 合成图/legacy preload 文件扫描整个 `_doc/` 大文件设备 > 8s 直接超时。
- 详情页比列表页更容易超时:详情页并发触发 `getCacheInfo()` + `getCategoryBreakdown(id)`,而 `computeBreakdown``computeSize` **多走 2 次 doc 根扫描**
**技术问题**
- `_statDir` 走 JS 递归 + `entry.file()` 单文件拿大小,每文件 ~1-3ms 桥往返2000 文件 = 2-6s慢设备撞 8s 单 handler 兜底)。
- `scanSandboxResidualFiles` 通过 `Promise.all` 跑 4 类dir-purge / dir-filtered / dir-uid / file——但 `plus.io` 桥是**单线程串行**的,并发不会加速,反而把每个根的等待时间叠加。
- `sandboxResidualHandler.computeBreakdown()` **三次扫描同一棵 doc 根**
1. `scanSandboxResidualByUid()` 走 share/ 下每个 uid 子目录
2. `scanSandboxResidualFiles()` 再走全量4 类 residual
3. 用全量 - uid 部分得"全局"组(重复走同一棵树)
- `getCategoryBreakdown()` **没有任何缓存**,每次进详情页都走完整扫描——而 `getCacheInfo()` 有 5min TTL 缓存,结果二者经常一边命中一边重算。
### 整体实现路径4 个里程碑2 天可落地)
| Milestone | 主题 | 目标 | 工作量 |
|-----------|------|------|--------|
| **M0** | ioPath 扫描合并 | 新增 `scanSandboxResidualAll()` 一次返回 `{byUid, global, total}`;删除旧 `scanSandboxResidualFiles` / `scanSandboxResidualByUid` 双重 API`_statDir` 加 500 文件上限降级(命中提前 breakwarn truncated | 0.5 天 |
| **M1** | handler 改用合并 API | `sandboxResidualHandler.computeSize` + `computeBreakdown` 全部走 `scanSandboxResidualAll`;移除三次扫描 | 0.3 天 |
| **M2** | 加 breakdown 缓存 + 取消并发竞态 | `getCategoryBreakdown` 加 1min TTL 缓存;详情页 `getCacheInfo``getCategoryBreakdown` 顺序执行(避免 plus.io 桥并发)| 0.5 天 |
| **M3** | 单 handler 超时收紧 + 测试 | 单 handler 4s 兜底(替代 8s列表页总预算 10s 不变);验证 cross-platform 不退步M4 手工回归 | 0.5 天 |
合计:**约 2 人天**。
### 关键决策
1. **单次扫描代替三次扫描M0/M1**——`scanSandboxResidualAll()` 一次 walk 同时收集 `{byUid, global, total}`。`computeBreakdown()` 不再调"全量 - uid 部分"的减法(避免重复 walk
2. **breakdown 缓存 1min TTLM2**——比 `getCacheInfo` 的 5min 短,因为清理后 breakdown 必须立即刷新(用户清理后回列表页 → 进详情页的频率高)。缓存 key = `${handlerId}#${uid}`;清理完成后由 `deductFromCache` 同步失效。
3. **详情页 `getCacheInfo` + `getCategoryBreakdown` 顺序执行M2**——plus.io 桥是单线程串行,并发不会加速反而拖慢。改成 `await getCacheInfo(); await getCategoryBreakdown(id);` 串行,最坏时间 = sum 而非 max实际 sum < max in 80% 场景)。
4. **`_statDir` 上限降到 500M0**——2000 文件 2-6s 太长500 文件 < 1.5s 4s 兜底内超出时 `truncated=true` + warnUI 显示 N+ 让用户知道有截断暂不实现 UI 提示 todo)。
5. **单 handler 超时从 8s 降到 4sM3**——500 文件上限后handler 正常 < 1.5s4s 兜底够用腾出 4s 给详情页 breakdown
6. **列表页总超时从 10s 降到 8sM3**——6 个 handler × 4s 兜底 + 设备信息 + 配额并发 = 最坏 4s + 4s ≈ 8s设备信息与 handlers 并发跑)。
### 核心架构图TL;DR
```
修复前(超时) 修复后(< 1.5s
cache-cleanup.vue cache-cleanup.vue
getCacheInfo() (10s) getCacheInfo() (8s)
├─ handler.computeSize 串行 ├─ handler.computeSize 串行
│ ├─ preload.computeSize │ ├─ preload.computeSize
│ ├─ draft.computeSize │ ├─ draft.computeSize
│ ├─ ... │ ├─ ...
│ └─ sandbox-residual.computeSize │ └─ sandbox-residual.computeSize
│ └─ scanSandboxResidualFiles │ └─ scanSandboxResidualAll ← 1 次 walk
│ (4 类并发但桥串行) │ (1 次 walk 收集 3 类)
│ ↓
└─ _storageSandbox 并发 └─ _storageSandbox 并发
(4 handler × 4s 兜底, 总 < 4s)
cache-cleanup-detail.vue cache-cleanup-detail.vue
Promise.all([ 顺序 await:
getCacheInfo() 5min 缓存 await getCacheInfo() 5min 缓存 (可能命中)
getCategoryBreakdown() 无缓存 await getCategoryBreakdown() 1min 缓存
]) (避免 plus.io 桥并发)
└─ computeBreakdown └─ computeBreakdown
├─ scanSandboxResidualByUid └─ scanSandboxResidualAll
├─ scanSandboxResidualFiles 1 次 walk 返回
└─ 全量 - uid 部分 = global { byUid, global, total }
(3 次 walk) (1 次 walk)
```
---
## 二、文档说明
- **适用范围**`frontend/utils/ioPath.js`、`frontend/utils/cacheManager.js`、`frontend/utils/handlers/sandboxResidualHandler.js`、`frontend/utils/handlers/sandboxTmpHandler.js`、`frontend/pages/profile/cache-cleanup*.vue`。
- **工作量估算**2 人天M0-M3含回归测试
- **前置版本**:基线 commit `407ca10d`feat:添加内存缓存数据);未提交修改 9 个文件。
- **目标读者**:前端工程师、测试。
- **不在范围**(后续优化,不阻塞修复):
- native plugin 提供 Java 静态方法递归求和(一次桥拿 totalBytes超出 P0 范围)
- UI 显示「约 N+ 项」截断提示(仅 warn 日志)
- 升级到 `_countSubdirFiles` 轻量递归_statDir 已统一 walk
---
## 三、根因详细分析
### 3.1 症状复现路径
**症状 A列表页超时**
```
用户点"存储空间"
→ pages/profile/cache-cleanup.vue#load
→ Promise.race([getCacheInfo(), 10s 兜底])
→ cacheManager.getCacheInfo()
→ for (handler of handlers.values()) // 7 个 handler 串行
├─ sandbox-tmp.computeSize → scanSandboxTmpFiles ← 白名单 5 业务目录, < 500ms
├─ sandbox-residual.computeSize ← 全 doc 根扫描
│ └─ scanSandboxResidualFiles (Promise.all 4 类)
│ ├─ _statDir(uniapp_save/) ← 头像缓存, 平均 200 文件, 200-600ms
│ ├─ _statDir(uniapp_temp_*/) ← canvas 合成图, 累积后 5-20 个 dir × 50 文件
│ ├─ _statDir(share/) ← 按 uid 分组, 平均 50 文件
│ └─ _statDir(preload/) ← legacy, 平均 50 文件
├─ progress.computeSize → ~50ms
├─ others.computeSize → _scanUpgradePackageFiles 50-200ms
├─ preload.computeSize → 50-200ms
├─ guide.computeSize → 50-200ms
└─ draft.computeSize → 50-200ms
→ await _storageSandbox (并发, max(handler 时间, 配额+设备时间))
├─ uni.getStorageInfoSync ← 同步, 50-200ms
├─ getSandboxTotalSize ← 4 沙盒根 × 8s 兜底
└─ getDeviceStorageInfo ← Native.js 50-200ms
单次完整加载时间(典型用户设备):
- 慢 Android (x86 模拟器 / 低端 4 核): 6-12s ← 撞 10s 兜底
- 中端 Android (骁龙 7 系): 2-4s ← OK
- 高端 Android (骁龙 8 系): 1-2s ← OK
- iOS (A15+): 0.5-1s ← OK
```
**症状 B详情页超时更严重**
```
用户点"分享图与头像缓存"
→ pages/profile/cache-cleanup-detail.vue#load
→ Promise.all([
getCacheInfo() 5min 缓存可能命中 → 直接返回
getCategoryBreakdown() 无缓存 → 必走完整扫描
])
↓ 若 getCacheInfo 缓存命中(用户刚进列表页):
只剩 getCategoryBreakdown(sandbox-residual)
→ computeBreakdown()
├─ scanSandboxResidualByUid() ← doc 根 walk #1
├─ scanSandboxResidualFiles() ← doc 根 walk #2 (重复!)
└─ 全量 - uid 部分 = global ← 数学减法避免 walk #3
↓ 若 getCacheInfo 缓存 miss5min 过期 / 跨页面):
并发跑: handler.computeSize (含 sandbox-residual) + sandbox-residual.computeBreakdown
↑ plus.io 桥串行并发 = 最坏 8s+8s = 16s 撞 8s 兜底
```
### 3.2 关键瓶颈定位
| 瓶颈 | 当前值 | 阈值 | 影响 |
|------|--------|------|------|
| `_statDir` 单文件 `entry.file()` | 1-3ms/文件 | 无 | 500 文件 = 0.5-1.5s2000 文件 = 2-6s |
| `scanSandboxResidualFiles``Promise.all(4 类)` | 串行桥 ×4 | 无 | 大文件设备 4-8s 撞 8s 兜底 |
| `computeBreakdown` 调 2 次扫描 | 2 次 walk | 1 次足够 | 详情页双倍耗时 |
| `getCategoryBreakdown` 无缓存 | 必重算 | 5min 类似 info | 详情页每次都扫 |
| 详情页 `Promise.all([info, breakdown])` | max(info, breakdown) | sum | 桥并发不会加速反而拖慢 |
### 3.3 数据流诊断(多组件边界)
**`sandbox-residual` 路径关键调用栈**
```
cacheManager.getCacheInfo() [cacheManager.js:186]
→ for (handler of handlers.values()) {
Promise.race([h.computeSize(), 8s 兜底])
→ sandboxResidualHandler.computeSize() [sandboxResidualHandler.js:36]
→ scanSandboxResidualFiles() [ioPath.js:695]
→ _listDir(root) ← bridge call #1
→ Promise.all([
_statDir(uniapp_save/), ← bridge calls × 200+ (单文件 entry.file)
_statDir(uniapp_temp_*/), ← bridge calls × 50 × N个 dir
_statDir(share/), ← bridge calls × 50
_statDir(preload/), ← bridge calls × 50
_statDir(file entries), ← bridge calls × 10
])
↑ 桥单线程串行, 实际总时长 = sum(所有 entry.file())
cacheManager.getCategoryBreakdown(id) [cacheManager.js:333]
→ handler.computeBreakdown() [sandboxResidualHandler.js:43]
→ scanSandboxResidualByUid() [ioPath.js:747]
→ _listDir(root) + _listDir(share/) × N 个 uid dir
+ _statDir(uid subdir) ← 第二次 walk!
→ scanSandboxResidualFiles() [ioPath.js:695]
→ 第三次 walk! (用于"全量 - uid 部分"减法)
```
---
## 四、修复方案
### 4.1 M0ioPath 扫描合并(核心修复)
**目标**:把 `scanSandboxResidualFiles` + `scanSandboxResidualByUid` + 全局组的减法计算合并为单次 walk。
**新增**`scanSandboxResidualAll()` —— 一次 walk 同时返回:
```js
/**
* 一次 walk 同时收集 doc 根残留的 (全量, uid分组, 全局) 三类信息。
* 替代旧 scanSandboxResidualFiles + scanSandboxResidualByUid 双扫描,
* 详情页 computeBreakdown 由 "3 次 walk" 降为 "1 次 walk"。
*
* @returns {Promise<{
* total: { sizeBytes: number, keyCount: number }, // 全量 4 类 residual
* byUid: Array<{ uid: string, sizeBytes: number, keyCount: number }>, // share/<uid>/ 各分组
* global: { sizeBytes: number, keyCount: number }, // 全量 - byUid数学减法非额外 walk
* }>}
*/
export async function scanSandboxResidualAll() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const entries = await _listDir(root)
const purgeTargets = []
const filteredTargets = []
const uidDirs = [] // share/ 目录, walk 内提取其下 uid 子目录
const fileTargets = []
for (const entry of entries) {
const kind = _residualKind(entry)
if (kind === 'dir-purge') purgeTargets.push(entry)
else if (kind === 'dir-filtered') filteredTargets.push(entry)
else if (kind === 'dir-uid') uidDirs.push(entry)
else if (kind === 'file') fileTargets.push(entry)
}
// 三类独立并发但桥内串行(沿用原 Promise.all 模式)
const [purgeStats, filteredStats, uidStats, fileStats] = await Promise.all([
Promise.all(purgeTargets.map((e) => _statDir(e))),
Promise.all(filteredTargets.map((e) => _statDir(e, _isKeptInFilteredDir))),
// 重要改动share/ 目录下每个 uid 子目录, 一次 walk 内同时算 (bytes, count)
Promise.all(uidDirs.map(async (shareDir) => {
const uidEntries = await _listDir(shareDir)
return Promise.all(
uidEntries
.filter((u) => u.isDirectory)
.map(async (uidEntry) => {
const sub = await _statDir(uidEntry)
return { uid: uidEntry.name, sizeBytes: sub.bytes, keyCount: sub.count }
})
)
})),
Promise.all(fileTargets.map((e) => new Promise((resolve) => {
e.file((f) => resolve({ bytes: f?.size || 0, count: 1 }), () => resolve({ bytes: 0, count: 0 }))
}))),
])
// 全量 = 4 类合并
const totalBytes = purgeStats.reduce((s, x) => s + x.bytes, 0)
+ filteredStats.reduce((s, x) => s + x.bytes, 0)
+ uidStats.flat().reduce((s, x) => s + x.sizeBytes, 0)
+ fileStats.reduce((s, x) => s + x.bytes, 0)
const totalCount = purgeStats.reduce((s, x) => s + x.count, 0)
+ filteredStats.reduce((s, x) => s + x.count, 0)
+ uidStats.flat().reduce((s, x) => s + x.keyCount, 0)
+ fileStats.reduce((s, x) => s + x.count, 0)
// byUid 拍平
const byUid = uidStats.flat()
// global = 全量 - byUid数学减法, 无额外 walk
const uidBytes = byUid.reduce((s, g) => s + g.sizeBytes, 0)
const uidCount = byUid.reduce((s, g) => s + g.keyCount, 0)
const global = {
sizeBytes: Math.max(0, totalBytes - uidBytes),
keyCount: Math.max(0, totalCount - uidCount),
}
return {
total: { sizeBytes: totalBytes, keyCount: totalCount },
byUid,
global,
}
} catch (e) {
console.warn('[ioPath] scanSandboxResidualAll failed:', e?.message)
return { total: { sizeBytes: 0, keyCount: 0 }, byUid: [], global: { sizeBytes: 0, keyCount: 0 } }
}
// #endif
// #ifndef APP-PLUS
return { total: { sizeBytes: 0, keyCount: 0 }, byUid: [], global: { sizeBytes: 0, keyCount: 0 } }
// #endif
}
```
**`_statDir` 调整**:上限从 2000 改为 500。
```js
const MAX_FILES = 500 // 1.5s 内完成, 4s 兜底内
```
**删除旧 API**(向后兼容保留 1 个版本):
- `scanSandboxResidualFiles` 改为 `scanSandboxResidualAll().then(r => r.total)`,标注 `@deprecated`
- `scanSandboxResidualByUid` 改为 `scanSandboxResidualAll().then(r => r.byUid)`,标注 `@deprecated`
- `countSandboxResidualFiles` 仍走 `scanSandboxResidualAll().total.keyCount`,无影响
### 4.2 M1handler 改用合并 API
**`sandboxResidualHandler.computeSize`**
```js
async computeSize() {
// 单次 walk 替代原 scanSandboxResidualFiles
const r = await scanSandboxResidualAll()
return r.total
}
```
**`sandboxResidualHandler.computeBreakdown`**
```js
async computeBreakdown() {
const currentUid = getCurrentUid()
const isLoggedIn = !!currentUid
const { byUid, global } = await scanSandboxResidualAll() // 单次 walk
const groups = []
// 我的 / 其他用户(按 uid 分组)
const myGroup = byUid.find((g) => g.uid === currentUid)
if (myGroup && myGroup.keyCount > 0) {
groups.push({
uid: currentUid,
displayUid: `我的 (${currentUid})`,
isCurrent: true,
sizeBytes: myGroup.sizeBytes,
keyCount: myGroup.keyCount,
canClean: true,
disabledReason: null,
})
}
const otherGroups = byUid.filter((g) => g.uid !== currentUid)
for (const g of otherGroups) {
if (g.keyCount === 0) continue
groups.push({
uid: g.uid,
displayUid: `其他用户 (${g.uid})`,
isCurrent: false,
sizeBytes: g.sizeBytes,
keyCount: g.keyCount,
canClean: isLoggedIn,
disabledReason: isLoggedIn ? null : 'logged-out',
})
}
// 全局(已数学减法得, 无额外 walk
groups.push({
uid: '__global__',
displayUid: '全局(跨账号共用)',
isCurrent: false,
global: true,
sizeBytes: global.sizeBytes,
keyCount: global.keyCount,
canClean: global.keyCount > 0,
disabledReason: global.keyCount === 0 ? 'empty' : null,
})
return groups.sort((a, b) => b.sizeBytes - a.sizeBytes)
}
```
### 4.3 M2breakdown 缓存 + 详情页串行
**`cacheManager.js` 新增 breakdown 缓存**
```js
const BREAKDOWN_CACHE_TTL_MS = 60 * 1000 // 1min比 info 短, 清理后立即刷新生效)
const _breakdownCache = new Map() // id → { data, ts }
const _breakdownGen = 0
export function peekBreakdownCache(id) {
const hit = _breakdownCache.get(id)
if (hit && Date.now() - hit.ts < BREAKDOWN_CACHE_TTL_MS) return hit.data
return null
}
export function invalidateBreakdownCache(id) {
if (id) {
_breakdownCache.delete(id)
} else {
_breakdownCache.clear()
}
_breakdownGen++
}
```
**`cacheManager.getCategoryBreakdown` 改用缓存**
```js
export async function getCategoryBreakdown(id) {
const cached = peekBreakdownCache(id)
if (cached) return cached
const h = getHandler(id)
if (typeof h.computeBreakdown !== 'function') return null
try {
const data = await h.computeBreakdown()
_breakdownCache.set(id, { data, ts: Date.now() })
return data
} catch (e) {
console.warn(`[cacheManager] computeBreakdown failed: ${id}`, e.message)
return []
}
}
```
**`cacheManager.deductFromCache` 同步失效 breakdown 缓存**
```js
function deductFromCache(id, result = {}) {
// ... 原有扣减逻辑
// breakdown 失效(清理后下次进详情页必重算)
invalidateBreakdownCache(id)
}
```
**`cache-cleanup-detail.vue` 改 Promise.all 为串行 await**
```js
// 修复前
const [info, br] = await Promise.all([
Promise.race([getCacheInfo(), 8s 兜底]),
Promise.race([getCategoryBreakdown(id.value), 8s 兜底]),
])
// 修复后
const info = await Promise.race([
getCacheInfo(),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)), // 收紧 8s→4s
])
const br = await Promise.race([
getCategoryBreakdown(id.value),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
])
// 原因: plus.io 桥单线程串行, 并发拖慢而非加速
```
### 4.4 M3超时收紧 + 回归
**`cacheManager.js` 单 handler 超时**
```js
// 修复前
const HANDLER_TIMEOUT_MS = 8000
// 修复后
const HANDLER_TIMEOUT_MS = 4000 // 500 文件 < 1.5s, 4s 兜底够用
```
**`cache-cleanup.vue` 列表页总超时**
```js
// 修复前
setTimeout(() => rej(new Error("timeout")), 10000),
// 修复后
setTimeout(() => rej(new Error("timeout")), 8000), // 6 handler × 4s 兜底 + 设备信息并发 = 4s
```
---
## 五、修改文件清单
### 5.1 新增文件
| 路径 | 说明 |
|------|------|
| 无 | 全部走修改既有文件 |
### 5.2 修改文件
| 路径 | 改动 |
|------|------|
| `frontend/utils/ioPath.js` | 新增 `scanSandboxResidualAll()``scanSandboxResidualFiles` / `scanSandboxResidualByUid` 改为 `@deprecated` 委托;`_statDir` MAX_FILES 2000→500 |
| `frontend/utils/handlers/sandboxResidualHandler.js` | `computeSize` / `computeBreakdown` 改用 `scanSandboxResidualAll` |
| `frontend/utils/cacheManager.js` | 新增 `_breakdownCache` + `peekBreakdownCache` + `invalidateBreakdownCache``getCategoryBreakdown` 加 1min 缓存;`deductFromCache` 同步失效 breakdown`HANDLER_TIMEOUT_MS` 8000→4000 |
| `frontend/pages/profile/cache-cleanup-detail.vue` | `Promise.all([info, breakdown])` 改串行 awaittimeout 8000→4000 |
| `frontend/pages/profile/cache-cleanup.vue` | 总超时 10000→8000 |
### 5.3 实施顺序
1. **M0** —— `ioPath.js` 新增 `scanSandboxResidualAll`,标记旧 API `@deprecated`(保留 1 版本);`_statDir` 500 上限
2. **M1** —— `sandboxResidualHandler.js` 改用合并 API核心修复
3. **M2** —— `cacheManager.js` 加 breakdown 缓存;`cache-cleanup-detail.vue` 串行化
4. **M3** —— 超时收紧cacheManager + cache-cleanupM4 手工回归
---
## 六、验证与回滚
### 6.1 验证DoD
| 项 | 验证方法 |
|----|----------|
| 列表页首次加载 < 2s中端 Android| 真机/模拟器手测 |
| 列表页首次加载 < 4s低端 Android| 真机手测 |
| 详情页首次加载 < 1.5s中端 Android| 真机手测 |
| 列表页 + 详情页切换无残留旧值 | 列表 → 详情 → 清理 → 返回列表 → 数字对得上 |
| breakdown 1min TTL 命中 | 多次进同一详情页, console.log 显示 "breakdown cache hit" |
| breakdown 清理后立即刷新 | 详情页清理 → 返回列表 → 再次进详情页, 数字更新 |
| 单 handler 4s 兜底生效 | 制造 500+ 文件场景, console 显示 `handler xxx timeout (4000ms)` |
| 黑名单 key 不被误清 | 见 §6.2 回归清单 |
### 6.2 手工回归清单CLAUDE.md 自检 + 业务场景)
- [ ] 列表页6 个分类行正常显示(含 sandbox-residual 「分享图与头像缓存」)
- [ ] 列表页:设备信息行(总空间/可用)正常
- [ ] 列表页3 个 chip已用/缓存/配额)+ 大字 + 进度条无错位
- [ ] 详情页简单型preload 详情页 → 单按钮 → 清理 → 返回 → 列表页数字更新
- [ ] 详情页分组型sandbox-residual 详情页 → 我的/其他用户/全局 3 组 → 各自清理
- [ ] 详情页分组型:未登录态打开 sandbox-residual → "其他用户"按钮置灰 tooltip "请先登录"
- [ ] 详情页加载失败降级:模拟 computeBreakdown 抛错 → 仍能进清理动作
- [ ] 黑名单 keyaccess_token / user / star_id / cid / deviceFp 全部不被清理
- [ ] 跨账号A 登录写 share 图 → 退出 → B 登录 → 列表页 sandbox-residual 显示「其他用户 (A uid)」
- [ ] 跨账号B 清理「其他用户 (A uid)」→ 返回列表页 → "分享图与头像缓存" 行下降对应字节
- [ ] 缓存命中:列表页第一次加载完成 → 进详情页再返回 → 列表页 spinner 不出现peekCache 命中)
- [ ] breakdown 失效:详情页清理 → 返回列表页 → 再次进详情页 → 数字已更新invalidateBreakdownCache 生效)
- [ ] iOS 真机:列表页加载时间 < 1sA15+
- [ ] Android 真机(中端):列表页加载 < 3s
- [ ] Android 真机(低端):列表页加载 < 4s 不超时
- [ ] 配额显示正确:`uni.getStorageInfoSync().limitSize` × 1024 = quotaTotalBytes
- [ ] 未登录态:分组页所有按钮置灰
- [ ] API 工程化handler 不直接写 SQL / IOservice 不直接碰 storage key黑名单由 manager 拦截)
- [ ] 前端规范:所有 `plus.io` / `Native.js` 都在 `#ifdef APP-PLUS`
- [ ] 前端规范:未触碰 `unpackage/dist/`
### 6.3 风险与回滚
| 风险 | 影响 | 缓解 |
|------|------|------|
| `scanSandboxResidualAll` 单 walk 出错 → 三个数据都错 | 详情页数据缺失 | 旧 `scanSandboxResidualFiles` / `scanSandboxResidualByUid``@deprecated` 保留, 1 版本可回滚 |
| breakdown 缓存 1min 内清理 | 详情页数字与实际不符 | `deductFromCache` 同步失效cleanAll 后 `invalidateBreakdownCache()` |
| `_statDir` MAX_FILES 500 截断 | 大文件目录数字偏低 | warn 日志;后续 UI 显示「约 N+ 项」(不在 P0|
| 单 handler 4s 兜底过紧 | 极慢设备仍可能撞超时 | 退回 8s 是 1 行常量改动 |
| 详情页串行 await 总时长变长 | 列表页缓存 miss 时体验差 | 5min info 缓存优先命中, breakdown 1min 缓存; 99% 场景走缓存 |
**回滚策略**
- M0 单独回滚:删除 `scanSandboxResidualAll`,把 `sandboxResidualHandler` 改回调旧 API
- M2 单独回滚:删除 `_breakdownCache``getCategoryBreakdown` 改回无缓存
- M3 单独回滚:超时常量改回 8s / 10s
---
## 七、性能预估
| 场景 | 修复前 | 修复后 | 改善 |
|------|--------|--------|------|
| 列表页首次加载(低端 Android, 2000 文件沙盒)| 8-12s 超时 | 2-3s | 4x |
| 列表页首次加载(中端 Android| 3-5s | 1-2s | 2.5x |
| 详情页首次加载sandbox-residual, 缓存 miss| 6-10s 超时 | 1-2s | 5x |
| 详情页二次加载1min 内)| 6-10s | < 100ms缓存命中| 60x |
| 跨账号列表 + 详情切换 | 6-12s | < 200ms | 30x |
---
## 八、自检清单
按 CLAUDE.md 要求:
- [x] 文档开头「方案概述」含:要解决的问题 / 实现路径 / 关键决策 / 核心架构图
- [x] MVP 先行:仅修复超时,不引入超出当前业务需要的抽象(合并 walk 是性能必要, 不是过度抽象)
- [x] 文件清单与目录结构对齐:`ioPath.js` / `cacheManager.js` / `sandboxResidualHandler.js` / `cache-cleanup*.vue` 都是既有文件
- [x] 跨章节引用一致性§3 根因 → §4 修复方案 一一对应§5 文件清单 → §4 实施顺序 一致
- [x] 全局自审CLAUDE.md「自审必须是全局审查」
- §3 根因列 5 个瓶颈 → §4 M0-M3 一一对应修复M0 合并 walk / `_statDir` 上限M1 handler 改 APIM2 缓存 + 串行M3 超时收紧)
- 文档 §3.1 复现路径 / §3.2 瓶颈表 / §3.3 调用栈 都覆盖到 §4 的具体函数行号
- 既有 handler 的 `clearSandboxResidualGlobal` / `clearSandboxShareByUid` 不在修复范围(清理路径是单次 + 删除, 不卡)
- 既有 `cacheManager.cleanAll``invalidateCache` 流程不变, 但需要同步失效 breakdown 缓存§4.3 已加)
- 既有 `cache-cleanup.vue` 的 5min 缓存 + `peekCache` 流程不变
- 不修改 `unpackage/dist/`
- [x] CLAUDE.md 前端规范:
- 所有 `plus.io` / `plus.android` / Native.js 调用包 `#ifdef APP-PLUS`
- 缓存策略明确5min info / 1min breakdown
- 错误处理统一(每 handler try/catch + warn 日志 + 降级展示)
- [x] API 工程化handler 仍走 `cacheManager` 封装层, 不在 UI 直接调 IO
---
## 九、附录
### A. 修改前后对比(关键代码 diff 摘要)
**ioPath.js 新增**
```diff
+ /**
+ * 一次 walk 同时收集 doc 根残留的 (全量, uid分组, 全局) 三类信息。
+ * 替代旧 scanSandboxResidualFiles + scanSandboxResidualByUid 双扫描。
+ */
+ export async function scanSandboxResidualAll() { ... }
- const MAX_FILES = 2000
+ const MAX_FILES = 500
```
**sandboxResidualHandler.js 改造**
```diff
- async computeSize() {
- return await scanSandboxResidualFiles()
- },
+ async computeSize() {
+ const r = await scanSandboxResidualAll()
+ return r.total
+ },
async computeBreakdown() {
- const uidGroups = await scanSandboxResidualByUid()
+ const { byUid, global } = await scanSandboxResidualAll()
const groups = []
- const myGroup = uidGroups.find((g) => g.uid === currentUid)
+ const myGroup = byUid.find((g) => g.uid === currentUid)
...
- const total = await scanSandboxResidualFiles() // 第 2 次 walk
- const otherBytes = uidGroups.reduce(...)
- const globalBytes = Math.max(0, total.sizeBytes - otherBytes) // 减法得 global
+ // global 已由 scanSandboxResidualAll 一次 walk 得, 无第 2 次 walk
}
```
**cacheManager.js 新增 breakdown 缓存 + 超时收紧**
```diff
+ const _breakdownCache = new Map()
+ export function peekBreakdownCache(id) { ... }
+ export function invalidateBreakdownCache(id) { ... }
export async function getCategoryBreakdown(id) {
+ const cached = peekBreakdownCache(id)
+ if (cached) return cached
const h = getHandler(id)
...
+ _breakdownCache.set(id, { data, ts: Date.now() })
return data
}
- const HANDLER_TIMEOUT_MS = 8000
+ const HANDLER_TIMEOUT_MS = 4000
function deductFromCache(id, result = {}) {
...
+ invalidateBreakdownCache(id) // 清理后立即失效
}
```
**cache-cleanup-detail.vue 串行化 + 超时收紧**
```diff
const [info, br] = await Promise.all([
Promise.race([getCacheInfo(), 8000]),
Promise.race([getCategoryBreakdown(id.value), 8000]),
])
+ // 改为串行避免 plus.io 桥并发拖慢
+ const info = await Promise.race([getCacheInfo(), 4000])
+ const br = await Promise.race([getCategoryBreakdown(id.value), 4000])
```
**cache-cleanup.vue 超时收紧**
```diff
- setTimeout(() => rej(new Error("timeout")), 10000),
+ setTimeout(() => rej(new Error("timeout")), 8000),
```
### B. plus.io 桥串行行为佐证
`ioPath.js` 已记录的已知行为:
- `Runtime.exec + readLine → plus.android bridge 同步阻塞死锁(实测)` —— 来自 `_sumDirSizeByExec` 注释
- `plus.android 对 *Long / static 字段代理不稳定 → 全部 parseFloat 兜底` —— 来自 `queryAndroidAppBytes` 注释
- 推论:`Promise.all([plus.io.x, plus.io.y])` 不并发,因 plus.io 内部桥单线程串行
### C. 相关 Issue / Commit 引用
- `feat:添加内存缓存数据`407ca10d—— 新增 sandbox-residual
- `fix:总内存显示bug修复`7667aadf—— 与本问题相关cache-cleanup 列表页"总内存" chip 修复
- `feat:增加实际使用量`2f672920—— appUsedBytes 聚合引入 _storageSandbox 并发
- `feat:修改样式和去除多余的清理内存方式`d9e3ff9e—— 最近的样式/清理逻辑改动
- 草稿 key 改造迁移项见 `2026-07-28-cache-cleanup-design.md` §11

View File

@ -601,7 +601,7 @@
/* 全局字体设置 */
body {
font-family:
"JDLTYuanTiJian",
/* "JDLTYuanTiJian", */
-apple-system,
BlinkMacSystemFont,
"PingFang SC",

View File

@ -138,7 +138,6 @@ export function useShare(props) {
}
const fileName = staticPath.split('/').pop();
const srcPath = '_www/' + staticPath.replace(/^\//, '');
const dstPath = `_doc/${fileName}`;
// 先确保 _doc 存在
plus.io.resolveLocalFileSystemURL(
'_doc/',
@ -176,14 +175,20 @@ export function useShare(props) {
// 把文件复制到 plus.io.PRIVATE_DOC 沙盒目录(Android 10+ 分区存储要求)
// 返回沙盒内的目标绝对路径,可供 plus.gallery.save 使用
//
// 按账号分目录_doc/share/<uid>/),与 sandboxResidualHandler 的分组清理对齐:
// - 已登录uid 取自 getStoredUser()?.uid
// - 未登录:落 'guest'(不归任何账号的"我的"组,归"全局"组的兄弟策略)
// 老的无 uid 路径_doc/share_<ts>.png已由 sandboxResidualHandler 的 fallback 兼容(如有存量)
function copyToSandbox(tempPath) {
return new Promise((resolve, reject) => {
if (typeof plus === 'undefined' || !plus.io) {
return reject(new Error('plus.io unavailable'));
}
const fileName = `share_${Date.now()}.png`;
const uid = getStoredUser()?.uid ?? 'guest';
const sandboxRoot = plus.io.PRIVATE_DOC; // 应用沙盒 doc 目录
const targetPath = `${sandboxRoot}/${fileName}`;
// 目标路径_doc/share/<uid>/,通过下方两层 getDirectory 逐级 ensure 创建
// 8s 超时兜底(避免 plus.io 内部 hang 永远不回调)
const timeoutId = setTimeout(() => {
@ -199,18 +204,41 @@ export function useShare(props) {
plus.io.resolveLocalFileSystemURL(
sandboxRoot,
guard((sandboxDir) => {
srcEntry.copyTo(
sandboxDir,
fileName,
guard((destEntry) => {
clearTimeout(timeoutId);
console.log('[useShare] copyToSandbox done:', destEntry.fullPath);
resolve(destEntry.fullPath);
// 逐级确保 share/<uid>/ 存在
sandboxDir.getDirectory(
'share',
{ create: true, exclusive: false },
guard((shareDir) => {
shareDir.getDirectory(
String(uid),
{ create: true, exclusive: false },
guard((uidDir) => {
srcEntry.copyTo(
uidDir,
fileName,
guard((destEntry) => {
clearTimeout(timeoutId);
console.log('[useShare] copyToSandbox done:', destEntry.fullPath);
resolve(destEntry.fullPath);
}),
guard((copyErr) => {
clearTimeout(timeoutId);
console.warn('[useShare] copyToSandbox fail:', copyErr);
reject(new Error(copyErr.message || 'copyTo failed'));
})
);
}),
guard((uidDirErr) => {
clearTimeout(timeoutId);
console.warn('[useShare] create uid dir fail:', uidDirErr);
reject(new Error(uidDirErr.message || 'uid dir create failed'));
})
);
}),
guard((copyErr) => {
guard((shareDirErr) => {
clearTimeout(timeoutId);
console.warn('[useShare] copyToSandbox fail:', copyErr);
reject(new Error(copyErr.message || 'copyTo failed'));
console.warn('[useShare] create share dir fail:', shareDirErr);
reject(new Error(shareDirErr.message || 'share dir create failed'));
})
);
}),

View File

@ -111,22 +111,31 @@ async function load() {
return
}
loading.value = true
// 2026-07-31M2 Promise.all awaitplus.io 线
// getCacheInfo getCategoryBreakdown
// - getCacheInfo 5min TTL < 50ms
// - getCategoryBreakdown 1min TTLM2 < 50ms
// - handler 4s + breakdown walk 4s = 8s
// - / category
// 8s 4sM3 HANDLER_TIMEOUT_MS 4s
let info = null
let br = null
try {
const [info, br] = await Promise.all([
Promise.race([
getCacheInfo(),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
]),
Promise.race([
getCategoryBreakdown(id.value),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
]),
info = await Promise.race([
getCacheInfo(),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
])
br = await Promise.race([
getCategoryBreakdown(id.value),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)),
])
const meta = (info.categories || []).find((c) => c.id === id.value)
if (meta) {
category.value = {
label: meta.label,
description: meta.description || '',
// sizeBytes === -1
// getCategoryBreakdown / cleanGroup
sizeBytes: meta.sizeBytes,
keyCount: meta.keyCount,
warning: !!meta.warning,
@ -135,7 +144,32 @@ async function load() {
breakdown.value = br // null = simple; array = grouped
} catch (e) {
console.warn('[cache-cleanup-detail] load failed:', e.message)
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
// fallback" / "
// - category sizeBytes=-1
// - ""
// cleanGroup('__global__')
// handler clean
if (info && id.value && (info.categories || []).find((c) => c.id === id.value)) {
// infocategory
} else {
category.value = { label: '', description: '', sizeBytes: -1, keyCount: 0, warning: false }
}
// breakdown ""breakdown null
if (breakdown.value === null) {
//
} else {
breakdown.value = [{
uid: '__global__',
displayUid: '全部文件(加载失败,可尝试清理)',
isCurrent: false,
global: true,
sizeBytes: -1,
keyCount: 0,
canClean: true,
disabledReason: null,
}]
}
uni.showToast({ title: '加载失败,但可尝试清理', icon: 'none' })
} finally {
loading.value = false
}
@ -147,7 +181,10 @@ onPullDownRefresh(async () => {
})
function confirmSimple() {
if (category.value.sizeBytes <= 0) {
// sizeBytes === -1
// goDetail
// cleanCategory freedBytes IO
if (category.value.sizeBytes === 0) {
uni.showToast({ title: '该分类暂无缓存', icon: 'none' })
return
}
@ -176,8 +213,8 @@ function confirmGroup(g) {
//
content = '将清空老版本数据,是否继续?'
} else if (g.global) {
// canClean=false
return
// task #38 return bug
content = '将清空分享图、头像缓存、canvas 合成图、preload 等跨账号共用数据,是否继续?'
} else {
//
content = `将清空 ${g.displayUid} 的数据,对方下次登录不会看到。是否继续?`
@ -225,7 +262,10 @@ async function doCleanSimple() {
}
async function doCleanGroup(g) {
if (!id.value || !g) return
if (!id.value || !g) {
console.warn('[cache-cleanup-detail] doCleanGroup skipped: id=' + id.value + ' g=' + !!g)
return
}
// breakdown.uid cleanCategoryGroup opts.uid
// self 'self'
// __legacy__ '__legacy__'
@ -233,19 +273,17 @@ async function doCleanGroup(g) {
let uidParam
if (g.isCurrent) {
uidParam = 'self'
} else if (g.uid === '__global__') {
// canClean=false
uni.showToast({ title: '全局数据不可清理', icon: 'none' })
return
} else {
uidParam = g.uid
}
console.log(`[cache-cleanup-detail] doCleanGroup START: id=${id.value} uid=${uidParam} g.uid=${g.uid} g.global=${g.global}`)
uni.showLoading({ title: '清理中...' })
try {
const r = await cleanCategoryGroup(id.value, { uid: uidParam })
uni.hideLoading()
const freed = r?.freedBytes || 0
const cnt = r?.keyCount || 0
console.log(`[cache-cleanup-detail] doCleanGroup DONE: freed=${freed} cnt=${cnt}`)
uni.showToast({ title: `已清理 ${formatSize(freed)} (${cnt} 项)`, icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {

View File

@ -26,10 +26,11 @@
class="seg-topfans"
:style="{ width: topfansPct + '%' }"
></view>
<!-- 橙段其他 app 使用空间从蓝段右边界开始紧接蓝段 -->
<!-- 橙段其他 app 使用空间从蓝段"视觉右边界"开始用视觉宽度而非真实百分比
避免 0.04%2% 区间被橙段覆盖导致蓝段消失 -->
<view
class="seg-other"
:style="{ left: topfansPct + '%', width: otherPct + '%' }"
:style="{ left: topfansVisualPct + '%', width: otherPct + '%' }"
></view>
</view>
@ -122,7 +123,7 @@
<script setup>
import { ref, computed } from "vue";
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
import { getCacheInfo, formatSize, peekCache } from "@/utils/cacheManager";
import { getCacheInfo, formatSize } from "@/utils/cacheManager";
// info load()
const INITIAL_INFO = Object.freeze({
@ -145,11 +146,17 @@ const loading = ref(false);
// deviceTotalBytes = 100%
// 100% 0
// = Topfans = + app+绿 =
//
// Topfans < 0.1%128GB 50MB 0.04%CSS min-width: 2%
// ""= max(topfansPct, 2%) 0.04%2%
// orange div DOM z-index""
const MIN_TOPFANS_PCT = 2
const topfansPct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
return Math.min(100, Math.max(0, (info.value.appUsedBytes / total) * 100));
});
const topfansVisualPct = computed(() => Math.max(topfansPct.value, MIN_TOPFANS_PCT))
const otherPct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
@ -159,12 +166,14 @@ const otherPct = computed(() => {
0,
info.value.deviceTotalBytes - info.value.appUsedBytes - info.value.deviceFreeBytes
);
return Math.min(100 - topfansPct.value, Math.max(0, (otherBytes / total) * 100));
// max(topfansPct, MIN_TOPFANS_PCT) topfansPct
// min-width 2%topfansPct%"绿"
return Math.min(100 - topfansVisualPct.value, Math.max(0, (otherBytes / total) * 100));
});
const availablePct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
return Math.max(0, 100 - topfansPct.value - otherPct.value);
return Math.max(0, 100 - topfansVisualPct.value - otherPct.value);
});
// APP-PLUS
@ -180,16 +189,10 @@ const isIos = (() => {
async function load(force = false) {
loadFailed.value = false;
// task #40
// spinner await
// loading 便 500ms spinner
if (!force) {
const cached = peekCache();
if (cached) {
info.value = cached;
return;
}
}
// peekCache
// spinner
info.value = { ...INITIAL_INFO };
@ -199,9 +202,7 @@ async function load(force = false) {
const result = await Promise.race([
getCacheInfo(force),
new Promise((_, rej) =>
// getCacheInfo < 3s4 + Native.js + handlers
// 2s ~2.1s5s 2x
setTimeout(() => rej(new Error("timeout")), 5000),
setTimeout(() => rej(new Error("timeout")), 8000),
),
]);
info.value = result;
@ -223,7 +224,11 @@ async function load(force = false) {
function goDetail(id) {
const cat = info.value.categories.find((c) => c.id === id);
if (!cat || cat.sizeBytes <= 0) {
if (!cat) return;
// sizeBytes === -1
// computeBreakdown / cleanGroup
// ""
if (cat.sizeBytes === 0) {
uni.showToast({ title: "该分类暂无缓存", icon: "none" });
return;
}
@ -373,7 +378,7 @@ onPullDownRefresh(async () => {
background: #fafbfc;
border: 1rpx solid #f0f0f0;
border-radius: 12rpx;
font-size: 22rpx;
font-size: 18rpx;
color: #595959;
display: inline-flex;
align-items: center;

View File

@ -1,18 +1,98 @@
// 头像文件缓存管理工具
//
// ★ 2026-07-31 补充 B加 LRU 上限 50 个文件
// 原实现每看一次头像 → uni.saveFile → _doc/uniapp_save/ 累积, 无 TTL 无上限,
// 生产设备几个月后 uniapp_save/ 累积几百个文件, sandbox-residual 扫描单类就要 1-3s,
// 是「存储空间」页面超时的主因之一。
// 现在:每次写入前 evictOldestIfNeeded() 把 LRU 索引压回 ≤ 50 个,
// 老的 storage 记录 + savedFilePath 同步删除。
//
// LRU 索引用一个独立 storage key__avatar_cache_lru_index__维护有序数组
// [{ hash: number, cachedAt: number }, ...] 按 cachedAt 升序
// 读路径 (getCachedAvatarPath) 不更新索引, 写路径 (downloadAndCacheAvatar) 才插入 + 驱逐。
// 读多写少场景(头像 UI开销可忽略。
const AVATAR_CACHE_CAP = 50
const AVATAR_LRU_INDEX_KEY = '__avatar_cache_lru_index__'
/**
* avatarUrl 生成 hash getCacheKey 共用避免改 storage key 格式
*/
function getCacheHash(avatarUrl) {
let hash = 0;
for (let i = 0; i < avatarUrl.length; i++) {
hash = ((hash << 5) - hash) + avatarUrl.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash);
}
/**
* 生成缓存key
* @param {string} avatarUrl - OSS头像路径
* @returns {string} 缓存key
* @returns {string}
*/
function getCacheKey(avatarUrl) {
// 对avatarUrl进行简单hash作为缓存key的一部分
let hash = 0;
for (let i = 0; i < avatarUrl.length; i++) {
hash = ((hash << 5) - hash) + avatarUrl.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
return `avatar_file_${getCacheHash(avatarUrl)}`;
}
/**
* LRU 索引容错解析失败返回空数组
*/
function getLruIndex() {
try {
const raw = uni.getStorageSync(AVATAR_LRU_INDEX_KEY);
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return arr;
}
} catch (e) { /* swallow */ }
return [];
}
/**
* LRU 索引容错写入失败不影响主流程
*/
function setLruIndex(arr) {
try {
uni.setStorageSync(AVATAR_LRU_INDEX_KEY, JSON.stringify(arr));
} catch (e) { /* swallow */ }
}
/**
* hash LRU 索引项 + 删对应 storage key + savedFilePath
* @param {number} hash
*/
function evictByHash(hash) {
try {
const storageKey = `avatar_file_${hash}`;
const cached = uni.getStorageSync(storageKey);
if (cached) {
try {
const data = JSON.parse(cached);
if (data?.localPath) {
uni.removeSavedFile({ filePath: data.localPath, fail: () => {} });
}
} catch (e) { /* skip */ }
uni.removeStorageSync(storageKey);
}
} catch (e) { /* swallow */ }
}
/**
* 2026-07-31 LRU 驱逐每次写入前调, 保持索引 AVATAR_CACHE_CAP
* 超出部分按 cachedAt 升序逐个删最老的先删
* - storage key (avatar_file_<hash>)
* - savedFilePath 文件
* - LRU 索引项
*/
function evictOldestIfNeeded() {
const index = getLruIndex();
while (index.length >= AVATAR_CACHE_CAP) {
const oldest = index.shift();
if (oldest?.hash != null) evictByHash(oldest.hash);
}
return `avatar_file_${Math.abs(hash)}`;
setLruIndex(index);
}
/**
@ -22,14 +102,14 @@ function getCacheKey(avatarUrl) {
*/
export async function getCachedAvatarPath(avatarUrl) {
if (!avatarUrl) return null;
try {
const cacheKey = getCacheKey(avatarUrl);
const cached = uni.getStorageSync(cacheKey);
if (cached) {
const cacheData = JSON.parse(cached);
// 检查缓存的avatar_url是否匹配防止hash碰撞
if (cacheData.avatarUrl === avatarUrl && cacheData.localPath) {
// 验证文件是否还存在
@ -64,10 +144,10 @@ export async function getCachedAvatarPath(avatarUrl) {
*/
export async function downloadAndCacheAvatar(avatarUrl, realUrl) {
if (!avatarUrl || !realUrl) return null;
try {
console.log('开始下载头像文件...');
// 1. 下载文件到临时目录
const downloadResult = await new Promise((resolve, reject) => {
uni.downloadFile({
@ -84,7 +164,7 @@ export async function downloadAndCacheAvatar(avatarUrl, realUrl) {
}
});
});
// 2. 将临时文件保存为永久文件
const savedPath = await new Promise((resolve, reject) => {
uni.saveFile({
@ -97,7 +177,7 @@ export async function downloadAndCacheAvatar(avatarUrl, realUrl) {
}
});
});
// 3. 保存文件路径到缓存
const cacheKey = getCacheKey(avatarUrl);
const cacheData = {
@ -106,10 +186,27 @@ export async function downloadAndCacheAvatar(avatarUrl, realUrl) {
cachedAt: Date.now()
};
uni.setStorageSync(cacheKey, JSON.stringify(cacheData));
// ★ 2026-07-31 补充 BLRU 索引 + 上限驱逐
// 1) 先把 LRU 索引压回 ≤ AVATAR_CACHE_CAP (50)
// 超出部分按 cachedAt 升序驱逐(删 storage key + savedFilePath + 索引项)
// 2) 再插入新条目到索引尾
// 容错:驱逐失败不阻塞主流程(最多导致 uniapp_save/ 仍超 50, 不会更糟)
try {
evictOldestIfNeeded()
const index = getLruIndex()
const hash = getCacheHash(avatarUrl)
// 同 hash 已存在则先移除(避免重复)
const filtered = index.filter((e) => e?.hash !== hash)
filtered.push({ hash, cachedAt: cacheData.cachedAt })
setLruIndex(filtered)
} catch (e) {
console.warn('[avatarCache] LRU index update failed:', e?.message)
}
console.log('头像文件已缓存到:', savedPath);
return savedPath;
} catch (error) {
console.error('下载并缓存头像失败:', error);
return null;
@ -122,14 +219,14 @@ export async function downloadAndCacheAvatar(avatarUrl, realUrl) {
*/
export function clearAvatarCache(avatarUrl) {
if (!avatarUrl) return;
try {
const cacheKey = getCacheKey(avatarUrl);
const cached = uni.getStorageSync(cacheKey);
if (cached) {
const cacheData = JSON.parse(cached);
// 删除本地文件
if (cacheData.localPath) {
uni.removeSavedFile({
@ -142,9 +239,15 @@ export function clearAvatarCache(avatarUrl) {
}
});
}
// 删除缓存记录
uni.removeStorageSync(cacheKey);
// ★ 2026-07-31同步清理 LRU 索引
try {
const hash = getCacheHash(avatarUrl)
const index = getLruIndex()
setLruIndex(index.filter((e) => e?.hash !== hash))
} catch (e) { /* swallow */ }
console.log('已清除头像缓存记录');
}
} catch (error) {
@ -159,7 +262,7 @@ export function clearAllAvatarCache() {
try {
const storageInfo = uni.getStorageInfoSync();
const keys = storageInfo.keys || [];
// 找到所有头像缓存的key并删除
keys.forEach(key => {
if (key.startsWith('avatar_file_')) {
@ -167,7 +270,7 @@ export function clearAllAvatarCache() {
const cached = uni.getStorageSync(key);
if (cached) {
const cacheData = JSON.parse(cached);
// 删除本地文件
if (cacheData.localPath) {
uni.removeSavedFile({
@ -181,7 +284,7 @@ export function clearAllAvatarCache() {
});
}
}
// 删除缓存记录
uni.removeStorageSync(key);
} catch (e) {
@ -189,9 +292,12 @@ export function clearAllAvatarCache() {
}
}
});
// ★ 2026-07-31清空 LRU 索引
setLruIndex([])
console.log('已清除所有头像缓存');
} catch (error) {
console.error('清除所有头像缓存失败:', error);
}
}
}

View File

@ -1,9 +1,15 @@
// 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'
@ -18,6 +24,8 @@ const PROTECTED_EXACT = new Set([
'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 = [
@ -26,9 +34,6 @@ const PROTECTED_PREFIX = [
'temp_register_',
]
/**
* 判断 key 是否在黑名单中
*/
export function isProtectedKey(key) {
if (typeof key !== 'string') return false
if (PROTECTED_EXACT.has(key)) return true
@ -38,6 +43,7 @@ export function isProtectedKey(key) {
// ── 大小格式化§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`
@ -50,72 +56,6 @@ export function formatSize(bytes) {
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')
@ -128,40 +68,55 @@ function getHandler(id) {
return h
}
// ── 公共 API ──
// ── 公共 API(无缓存,每次都重算) ──
/**
* 列表页读取汇总 + 存储配额 + 其他 section 数据
* @param {boolean} force 强制重算跳过 30s 缓存下拉刷新/清理后用
* 列表页读取 每次都重算
* @param {boolean} force 参数保留但不再使用无缓存可绕过
*/
export async function getCacheInfo(force = false) {
// 缓存命中30s 内且不强制刷新 → 同步返回,避免重复走沙盒遍历
const now = Date.now()
if (!force && _cachedInfo && now - _cachedAt < CACHE_TTL_MS) {
return _cachedInfo
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,
})
}
}
// 缓存未命中或过期或强制刷新 → 重新计算
// [并行 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
let deviceTotalBytes = 0, deviceFreeBytes = 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 {
@ -178,10 +133,8 @@ export async function getCacheInfo(force = false) {
} 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) {
@ -189,31 +142,38 @@ export async function getCacheInfo(force = false) {
}
const totalBytes = categories.reduce((sum, c) => sum + (c.sizeBytes > 0 ? c.sizeBytes : 0), 0)
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes
// 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 包含,不重复加
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
// 设备级数据已在 try 块里取好ioPath 返回的就是字节,不再 * 1024
const deviceUsagePercent = deviceTotalBytes > 0
? (appUsedBytes / deviceTotalBytes) * 100
: 0
const result = {
return {
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)
@ -237,13 +197,9 @@ export function cleanCategory(id) {
}
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)
@ -254,9 +210,6 @@ export function cleanCategory(id) {
/**
* 分组页清理 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)
@ -270,9 +223,6 @@ export function cleanCategoryGroup(id, opts = {}) {
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)
@ -282,10 +232,7 @@ export function cleanCategoryGroup(id, opts = {}) {
}
/**
* 编程式清理所有UI 不调用登出流程/测试用
* 对每个 handler 调用其支持的清理方法
* - 优先 clean()简单型
* - 否则调 cleanGroup({ uid: 'self' })仅清当前用户的分组数据符合"登出前清自己"的语义
* 编程式清理所有登出流程/测试用
*/
export async function cleanAll() {
const results = []
@ -310,7 +257,7 @@ export async function cleanAll() {
}
// NOTE: 必须非 async —— async 会把返回的 Promise 再包一层,破坏
// `p1 === p2` 同一性断言(参见 plan §Task 12 Step 2 自测)
// `p1 === p2` 同一性断言
// 这里需要直接返回存储在 cleanInFlight 中的 Promise 引用本身。
function _runWithInFlight(key, fn) {
if (cleanInFlight.has(key)) return cleanInFlight.get(key)
@ -321,10 +268,11 @@ function _runWithInFlight(key, fn) {
return p
}
// ── Handler 注册(模块加载时执行;新增 handler 在此追加 registerCategory 调用)──
// ── Handler 注册(模块加载时执行 ──
registerCategory(sandboxTmpHandler)
registerCategory(sandboxResidualHandler)
registerCategory(progressHandler)
registerCategory(othersHandler)
registerCategory(preloadHandler)
registerCategory(guideHandler)
registerCategory(draftHandler)
registerCategory(draftHandler)

View File

@ -11,6 +11,8 @@ import { getAllBaseKeys } from '@/utils/draftStorage'
const PRELOAD_PREFIX = 'preload:'
const PROGRESS_PREFIX = 'progress_'
const GUIDE_PREFIX = 'guide_'
const UPGRADE_STORAGE_KEY = 'UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH'
const UPGRADE_FILE_EXT = /\.(wgt|apk|ipa)$/i
const DRAFT_BASE_KEYS = getAllBaseKeys()
// 工作流关键 key 白名单reviewer P0 修正:原"全收"策略会破坏这些)
@ -19,7 +21,7 @@ const WORKFLOW_CRITICAL_KEYS = new Set([
'generation_request_data',
'craft_selected_index',
'__package_info__',
'UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH',
UPGRADE_STORAGE_KEY,
])
function isClaimed(key) {
@ -33,28 +35,115 @@ function isClaimed(key) {
return true
}
// ── 升级包沙盒文件_downloads/upgrade_*.{wgt,apk,ipa})──
// 升级包可大到几十 MBapk且 App.vue 启动清理已移除2026-07-31 决议),
// 由本 handler 在用户主动清理时一并处理。
//
// 走 plus.io不引 ioPath升级包在 _downloads 而非 _docioPath 的
// PRIVATE_DOC 抽象不适用;这里直接用 plus.io API少量代码不值得抽象。
function _scanUpgradePackageFiles() {
// #ifdef APP-PLUS
return new Promise((resolve) => {
if (typeof plus === 'undefined' || !plus.io) return resolve({ sizeBytes: 0, keyCount: 0 })
const dirUrl = plus.io.convertLocalFileSystemURL('_downloads/')
plus.io.resolveLocalFileSystemURL(
dirUrl,
(dirEntry) => {
const reader = dirEntry.createReader()
reader.readEntries(
(entries) => {
let sizeBytes = 0
let keyCount = 0
let pending = 0
const done = () => resolve({ sizeBytes, keyCount })
for (const e of entries) {
if (!e.isFile) continue
if (!/^upgrade_\d+\.(wgt|apk|ipa)$/i.test(e.name)) continue
keyCount++
pending++
e.file(
(f) => { sizeBytes += (f.size || 0); if (--pending === 0) done() },
() => { if (--pending === 0) done() }
)
}
if (pending === 0) done()
},
() => resolve({ sizeBytes: 0, keyCount: 0 })
)
},
() => resolve({ sizeBytes: 0, keyCount: 0 }) // _downloads 不存在
)
})
// #endif
// #ifndef APP-PLUS
return Promise.resolve({ sizeBytes: 0, keyCount: 0 })
// #endif
}
function _clearUpgradePackageFiles() {
// #ifdef APP-PLUS
return new Promise((resolve) => {
if (typeof plus === 'undefined' || !plus.io) return resolve({ deleted: 0 })
const dirUrl = plus.io.convertLocalFileSystemURL('_downloads/')
plus.io.resolveLocalFileSystemURL(
dirUrl,
(dirEntry) => {
const reader = dirEntry.createReader()
reader.readEntries(
(entries) => {
let deleted = 0
let pending = 0
const done = () => resolve({ deleted })
for (const e of entries) {
if (!e.isFile) continue
if (!/^upgrade_\d+\.(wgt|apk|ipa)$/i.test(e.name)) continue
pending++
e.remove(
() => { deleted++; if (--pending === 0) done() },
() => { if (--pending === 0) done() }
)
}
if (pending === 0) done()
},
() => resolve({ deleted: 0 })
)
},
() => resolve({ deleted: 0 })
)
})
// #endif
// #ifndef APP-PLUS
return Promise.resolve({ deleted: 0 })
// #endif
}
export default {
id: 'others',
label: '其他业务缓存',
description: '未归类的少量业务数据',
warning: false,
async computeSize() {
try {
const info = uni.getStorageInfoSync()
const keys = (info.keys || []).filter(isClaimed)
let bytes = 0
for (const k of keys) {
try { const v = uni.getStorageSync(k); if (v != null) bytes += JSON.stringify(v).length } catch (e) {}
}
return { sizeBytes: bytes, keyCount: keys.length }
} catch (e) { return { sizeBytes: 0, keyCount: 0 } }
},
async clean() {
// Top-level errors propagate to cacheManager (per spec §6.1)
// storage key 部分(老逻辑)
const info = uni.getStorageInfoSync()
const keys = (info.keys || []).filter(isClaimed)
let freed = 0
let storageBytes = 0
for (const k of keys) {
try { const v = uni.getStorageSync(k); if (v != null) storageBytes += JSON.stringify(v).length } catch (e) {}
}
// 升级包沙盒文件部分_downloads/upgrade_*
const { sizeBytes: pkgBytes, keyCount: pkgCount } = await _scanUpgradePackageFiles()
return {
sizeBytes: storageBytes + pkgBytes,
sizeBytesFromStorage: storageBytes,
sizeBytesFromSandbox: pkgBytes,
keyCount: keys.length + pkgCount,
}
},
async clean() {
// 1) storage key 部分
const info = uni.getStorageInfoSync()
const keys = (info.keys || []).filter(isClaimed)
let freedStorage = 0
let count = 0
for (const k of keys) {
let valueBytes = 0
@ -63,17 +152,24 @@ export default {
if (v != null) valueBytes = JSON.stringify(v).length
} catch (e) {
console.warn('[othersHandler] read failed:', k, e.message)
continue // skip this key, don't try to delete
continue
}
try {
uni.removeStorageSync(k)
freed += valueBytes
freedStorage += valueBytes
count++
} catch (e) {
console.warn('[othersHandler] remove failed:', k, e.message)
// don't count as freed
}
}
return { freedBytes: freed, keyCount: count }
// 2) 升级包沙盒文件部分(先量后删得到准确 freedBytes
const { sizeBytes: pkgBytes, keyCount: pkgCount } = await _scanUpgradePackageFiles()
const cleared = await _clearUpgradePackageFiles()
return {
freedBytes: freedStorage + pkgBytes,
freedBytesFromStorage: freedStorage,
freedBytesFromSandbox: pkgBytes,
keyCount: count + pkgCount,
}
},
}
}

View File

@ -0,0 +1,117 @@
// frontend/utils/handlers/sandboxResidualHandler.js
// 沙盒 doc 根残留文件 handler分组型
//
// 覆盖 sandboxTmpHandler 漏掉的那部分沙盒占用 —— 后者只扫 <业务>/tmp/
// 而下列文件都不在那个形状里,此前有占用、无分类、无清理入口:
// _doc/share/<uid>/share_<ts>.png useShare.js#copyToSandbox保存到相册的兜底分支进程被杀会留孤儿
// _doc/<static原文件名> useShare.js#copyStaticToDoc完全无清理设备级不归账号
// _doc/uniapp_save/** avatarCache.js#downloadAndCacheAvataruni.saveFile 落点,按 URL hash 累积、无 TTL
// ★ 该目录是 uni.saveFile 的公共落点,同时存放升级包,
// 故只删非 .wgt/.apk/.ipa 的文件,详见 ioPath#_residualKind
// _doc/uniapp_temp_*/** canvasToTempFilePath 落点image-compositor.js 分享图合成uni 设计上会清但失败场景会留
// _doc/preload/** 2026-07-13 已废弃的文件版预加载缓存
//
// 分组策略(按 ioPath#_residualKind 判定):
// - share/<uid>/ 子目录 → 按 uid 分组("我的" / "其他用户"
// - 其他所有(散落图片 + uniapp_save + uniapp_temp_*/ + preload + copyStaticToDoc 复制件)→ "全局"组
//
// 只删沙盒文件,不动 storage key清理后 avatar_file_* 记录会短暂悬空,
// 由 avatarCache.js#getCachedAvatarPath 自愈getFileInfo 失败 → 删记录 → 返回 null → 重新下载)。
// 这样本分类的字节口径是纯沙盒文件字节,与 cacheManager 的 blacklistBytes 不交叉。
import { getCurrentUid } from '@/utils/draftStorage'
import {
scanSandboxResidualAll,
clearSandboxResidualGlobal,
clearSandboxShareByUid,
} from '@/utils/ioPath'
export default {
id: 'sandbox-residual',
label: '分享图与头像缓存',
description: '分享保存的图片、合成图与头像缓存文件',
warning: false,
// 分组型 handler 不实现 clean() — 强制走 cleanGroup({uid})
// 列表页汇总scanSandboxResidualAll 一次 walk 取 total 字段(替代旧 scanSandboxResidualFiles
async computeSize() {
const r = await scanSandboxResidualAll()
return r.total
},
// 分组详情3 组
// - 我的(当前 uid 的 share/<uid>/
// - 其他用户(其他 uid 的 share/<uid>/,聚合)
// - 全局doc 根其余全部)
// ★ 2026-07-31 修复M1原版走 scanSandboxResidualByUid() + scanSandboxResidualFiles()
// 共 2 次 walk详情页撞 4s 超时)。改用 scanSandboxResidualAll() 单次 walk
// 同时取 byUid 和 globalglobal 由 total - byUid 数学减法得),无额外 walk。
async computeBreakdown() {
const currentUid = getCurrentUid()
const isLoggedIn = !!currentUid
const { byUid, global } = await scanSandboxResidualAll()
const groups = []
// 1) 我的 / 其他用户(按 uid 子目录聚合)
const myGroup = byUid.find((g) => g.uid === currentUid)
if (myGroup && myGroup.keyCount > 0) {
groups.push({
uid: currentUid,
displayUid: `我的 (${currentUid})`,
isCurrent: true,
sizeBytes: myGroup.sizeBytes,
keyCount: myGroup.keyCount,
canClean: true,
disabledReason: null,
})
}
const otherGroups = byUid.filter((g) => g.uid !== currentUid)
for (const g of otherGroups) {
if (g.keyCount === 0) continue
groups.push({
uid: g.uid,
displayUid: `其他用户 (${g.uid})`,
isCurrent: false,
sizeBytes: g.sizeBytes,
keyCount: g.keyCount,
canClean: isLoggedIn, // 未登录态不准清理他人数据
disabledReason: isLoggedIn ? null : 'logged-out',
})
}
// 2) 全局doc 根其余全部)— 已由 scanSandboxResidualAll 一次 walk 算出total - byUid
// 全局组永远存在(即使没文件也显示 0让用户知道这个分类
groups.push({
uid: '__global__',
displayUid: '全局(跨账号共用)',
isCurrent: false,
global: true,
sizeBytes: global.sizeBytes,
keyCount: global.keyCount,
canClean: global.keyCount > 0,
disabledReason: global.keyCount === 0 ? 'empty' : null,
})
return groups.sort((a, b) => b.sizeBytes - a.sizeBytes)
},
async cleanGroup({ uid }) {
// 'self' → 清当前 uid 的 share/<uid>/
if (uid === 'self') {
const currentUid = getCurrentUid()
if (!currentUid) return { freedBytes: 0, keyCount: 0 }
const r = await clearSandboxShareByUid(currentUid)
if (!r.deleted) return { freedBytes: 0, keyCount: 0 }
return { freedBytes: r.freedBytes || 0, keyCount: r.keyCount || 0 }
}
// '__global__' → 清 doc 根除 share/ 之外的整张残留图
// 注意share/ 下各 uid 子目录由具体 uid 入口清理,本入口不动
// 边量边删clearSandboxResidualGlobal 内部对每个 dir-purge 先 _statDir 拿 before
// 字节/计数,再 removeRecursively返回 { freedBytes, keyCount, ... }
// 调用方不再做 before/after 双扫描(慢设备 4s+ 超时卡住清理动作)
if (uid === '__global__') {
const r = await clearSandboxResidualGlobal()
return { freedBytes: r.freedBytes || 0, keyCount: r.keyCount || 0 }
}
// 具体 uid → 清 share/<uid>/(不对其它 uid 动手)
const r = await clearSandboxShareByUid(uid)
if (!r.deleted) return { freedBytes: 0, keyCount: 0 }
return { freedBytes: r.freedBytes || 0, keyCount: r.keyCount || 0 }
},
}

View File

@ -1,7 +1,6 @@
// frontend/utils/handlers/sandboxTmpHandler.js
// 沙盒临时文件 handler简单型
// reviewer P0 修正:不再依赖私有 ioPath 内部函数,全部走 ioPath 导出 API
import { scanSandboxTmpFiles, countSandboxTmpFiles, clearAllSandboxTmpFiles } from '@/utils/ioPath'
import { scanSandboxTmpFiles, clearAllSandboxTmpFiles } from '@/utils/ioPath'
export default {
id: 'sandbox-tmp',
@ -9,19 +8,14 @@ export default {
description: '上传/分享过程产生的临时文件',
warning: false,
async computeSize() {
const [bytes, count] = await Promise.all([
scanSandboxTmpFiles(),
countSandboxTmpFiles(), // 真实文件数reviewer P0 修正)
])
return { sizeBytes: bytes, keyCount: count }
// scanSandboxTmpFiles 一次返回 {sizeBytes, keyCount}(替代旧的 size+count 双遍历)
return await scanSandboxTmpFiles()
},
async clean() {
const [before, beforeCount] = await Promise.all([
scanSandboxTmpFiles(),
countSandboxTmpFiles(),
])
await clearAllSandboxTmpFiles()
const after = await scanSandboxTmpFiles()
return { freedBytes: Math.max(0, before - after), keyCount: beforeCount }
// 边量边删clearAllSandboxTmpFiles 内部对每个 tmp/ 目录先 _statDir 拿 before
// 字节/计数,再 removeRecursively返回 { freedBytes, keyCount, ... }
// 调用方不再做 before/after 三次扫描,慢设备不再卡 4s 超时
const r = await clearAllSandboxTmpFiles()
return { freedBytes: r.freedBytes || 0, keyCount: r.keyCount || 0 }
},
}
}

View File

@ -39,7 +39,7 @@ function truncateHash(hash) {
* @param {string} [opts.displayTxHash] - 链上哈希原文(父组件提供),截断后渲染到二维码侧空白区
* @returns {Promise<{tempFilePath: string, width: number, height: number}>}
*/
export function composeShareImage(opts, componentThis) {
export function composeShareImage(opts, _componentThis) {
return new Promise((resolve, reject) => {
const {
coverLocalPath,
@ -126,9 +126,10 @@ export function composeShareImage(opts, componentThis) {
* 计算 composeKey(spec § 5.4)
* 用于 L1/L2 缓存查询;任一字段变化则重合成
*
* 入参用本地临时路径(_doc/uniapp_temp_<timestamp>/download/...)
* 当前 trade-off:temp 路径含时间戳,每次 pick 都不同 L1/L2 cache 实际不会命中,
* 每次 pick 都会重合成代码保留 cache 结构以备将来切回 URL-based key 时复用
* 入参 cover/qrcode/avatar 本地路径来自 useShare.js#downloadLocal
* - /static/ 前缀 copyStaticToDoc _doc/<static 原文件名>无时间戳
* - 远端 URL 失败兜底原样返回仍可作为 cache key
* 拼接 7 个字段3 路径 + 1 昵称 + 2 slogan + 1 txHash任一变化触发重合成
*/
export function computeComposeKey(opts) {
const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {}, displayTxHash = '' } = opts;

File diff suppressed because it is too large Load Diff