fix:修复存储空间bug
This commit is contained in:
parent
d9e3ff9eae
commit
9e02001496
@ -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` 分发到简单/分组模板) |
|
||||
|
||||
674
docs/specs/2026-07-31-cache-timeout-remediation.md
Normal file
674
docs/specs/2026-07-31-cache-timeout-remediation.md
Normal 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 文件上限降级(命中提前 break,warn 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 TTL(M2)**——比 `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` 上限降到 500(M0)**——2000 文件 2-6s 太长;500 文件 < 1.5s 在 4s 兜底内。超出时 `truncated=true` + warn,UI 显示「约 N+ 项」让用户知道有截断(暂不实现 UI 提示,留 todo)。
|
||||
5. **单 handler 超时从 8s 降到 4s(M3)**——500 文件上限后,handler 正常 < 1.5s;4s 兜底够用;腾出 4s 给详情页 breakdown 用。
|
||||
6. **列表页总超时从 10s 降到 8s(M3)**——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 缓存 miss(5min 过期 / 跨页面):
|
||||
并发跑: handler.computeSize (含 sandbox-residual) + sandbox-residual.computeBreakdown
|
||||
↑ plus.io 桥串行并发 = 最坏 8s+8s = 16s 撞 8s 兜底
|
||||
```
|
||||
|
||||
### 3.2 关键瓶颈定位
|
||||
|
||||
| 瓶颈 | 当前值 | 阈值 | 影响 |
|
||||
|------|--------|------|------|
|
||||
| `_statDir` 单文件 `entry.file()` | 1-3ms/文件 | 无 | 500 文件 = 0.5-1.5s;2000 文件 = 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 M0:ioPath 扫描合并(核心修复)
|
||||
|
||||
**目标**:把 `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 M1:handler 改用合并 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 M2:breakdown 缓存 + 详情页串行
|
||||
|
||||
**`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])` 改串行 await;timeout 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-cleanup);M4 手工回归
|
||||
|
||||
---
|
||||
|
||||
## 六、验证与回滚
|
||||
|
||||
### 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 抛错 → 仍能进清理动作
|
||||
- [ ] 黑名单 key:access_token / user / star_id / cid / deviceFp 全部不被清理
|
||||
- [ ] 跨账号:A 登录写 share 图 → 退出 → B 登录 → 列表页 sandbox-residual 显示「其他用户 (A uid)」
|
||||
- [ ] 跨账号:B 清理「其他用户 (A uid)」→ 返回列表页 → "分享图与头像缓存" 行下降对应字节
|
||||
- [ ] 缓存命中:列表页第一次加载完成 → 进详情页再返回 → 列表页 spinner 不出现(peekCache 命中)
|
||||
- [ ] breakdown 失效:详情页清理 → 返回列表页 → 再次进详情页 → 数字已更新(invalidateBreakdownCache 生效)
|
||||
- [ ] iOS 真机:列表页加载时间 < 1s(A15+)
|
||||
- [ ] Android 真机(中端):列表页加载 < 3s
|
||||
- [ ] Android 真机(低端):列表页加载 < 4s 不超时
|
||||
- [ ] 配额显示正确:`uni.getStorageInfoSync().limitSize` × 1024 = quotaTotalBytes
|
||||
- [ ] 未登录态:分组页所有按钮置灰
|
||||
- [ ] API 工程化:handler 不直接写 SQL / IO;service 不直接碰 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 改 API;M2 缓存 + 串行;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
|
||||
@ -601,7 +601,7 @@
|
||||
/* 全局字体设置 */
|
||||
body {
|
||||
font-family:
|
||||
"JDLTYuanTiJian",
|
||||
/* "JDLTYuanTiJian", */
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"PingFang SC",
|
||||
|
||||
@ -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'));
|
||||
})
|
||||
);
|
||||
}),
|
||||
|
||||
@ -111,22 +111,31 @@ async function load() {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// ★ 2026-07-31(M2)改 Promise.all 为串行 await:plus.io 桥是单线程串行的,
|
||||
// 并发拖慢而非加速。详情页依次走 getCacheInfo → getCategoryBreakdown。
|
||||
// - getCacheInfo 5min TTL 通常命中(用户从列表页进来刚算过),< 50ms
|
||||
// - getCategoryBreakdown 1min TTL(M2 新增),二次进入 < 50ms
|
||||
// - 首次进入:handler 4s + breakdown walk 4s = 最坏 8s
|
||||
// - 撞超时各自走降级路径(合成分组 / 保留 category)
|
||||
// 收紧超时 8s → 4s(M3):配合 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)) {
|
||||
// 至少有 info;category 已经被赋值,跳过合成
|
||||
} 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) {
|
||||
|
||||
@ -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 正常 < 3s(4 沙盒根 + Native.js + handlers 并行)
|
||||
// 单根 2s 超时兜底后最长 ~2.1s,5s 留 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;
|
||||
|
||||
@ -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 补充 B:LRU 索引 + 上限驱逐
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 * 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
|
||||
// 设备级数据已在 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)
|
||||
|
||||
@ -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})──
|
||||
// 升级包可大到几十 MB(apk),且 App.vue 启动清理已移除(2026-07-31 决议),
|
||||
// 由本 handler 在用户主动清理时一并处理。
|
||||
//
|
||||
// 走 plus.io(不引 ioPath):升级包在 _downloads 而非 _doc,ioPath 的
|
||||
// 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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
117
frontend/utils/handlers/sandboxResidualHandler.js
Normal file
117
frontend/utils/handlers/sandboxResidualHandler.js
Normal 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#downloadAndCacheAvatar(uni.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 和 global(global 由 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 }
|
||||
},
|
||||
}
|
||||
@ -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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user