diff --git a/docs/specs/2026-07-28-cache-cleanup-design.md b/docs/specs/2026-07-28-cache-cleanup-design.md index 40a3a73..e80964a 100644 --- a/docs/specs/2026-07-28-cache-cleanup-design.md +++ b/docs/specs/2026-07-28-cache-cleanup-design.md @@ -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//` 子目录分组("我的" / "其他用户" / "全局"),与 `draft` / `guide` 一致。写入侧(`useShare.js#copyToSandbox`)改为 `_doc/share//` 路径,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//` 按账号分目录,分组清理由 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` 分发到简单/分组模板) | diff --git a/docs/specs/2026-07-31-cache-timeout-remediation.md b/docs/specs/2026-07-31-cache-timeout-remediation.md new file mode 100644 index 0000000..09eb1ea --- /dev/null +++ b/docs/specs/2026-07-31-cache-timeout-remediation.md @@ -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// 各分组 + * 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 \ No newline at end of file diff --git a/frontend/App.vue b/frontend/App.vue index 1e694bd..f3e6ef5 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -601,7 +601,7 @@ /* 全局字体设置 */ body { font-family: - "JDLTYuanTiJian", + /* "JDLTYuanTiJian", */ -apple-system, BlinkMacSystemFont, "PingFang SC", diff --git a/frontend/composables/useShare.js b/frontend/composables/useShare.js index 06bbe5f..d21bd10 100644 --- a/frontend/composables/useShare.js +++ b/frontend/composables/useShare.js @@ -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//),与 sandboxResidualHandler 的分组清理对齐: + // - 已登录:uid 取自 getStoredUser()?.uid + // - 未登录:落 'guest'(不归任何账号的"我的"组,归"全局"组的兄弟策略) + // 老的无 uid 路径(_doc/share_.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//,通过下方两层 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// 存在 + 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')); }) ); }), diff --git a/frontend/pages/profile/cache-cleanup-detail.vue b/frontend/pages/profile/cache-cleanup-detail.vue index b111cb7..f12b96d 100644 --- a/frontend/pages/profile/cache-cleanup-detail.vue +++ b/frontend/pages/profile/cache-cleanup-detail.vue @@ -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) { diff --git a/frontend/pages/profile/cache-cleanup.vue b/frontend/pages/profile/cache-cleanup.vue index 51148e6..71f3cc6 100644 --- a/frontend/pages/profile/cache-cleanup.vue +++ b/frontend/pages/profile/cache-cleanup.vue @@ -26,10 +26,11 @@ class="seg-topfans" :style="{ width: topfansPct + '%' }" > - + @@ -122,7 +123,7 @@