topfans/docs/specs/2026-07-28-cache-cleanup-design.md
2026-07-29 16:03:33 +08:00

680 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 缓存清理功能设计2026-07-28
> 配套前置分析:本次会话中已扫描 `frontend/` 全量代码,识别出 4 大类存储uni 本地持久化、内存缓存、临时文件、沙盒文件系统)。本文把"统一清理入口"这一需求转化为**可执行的 MVP 实施方案**。
---
## 一、方案概述(必读)
### 要解决的问题
**业务问题**
- 用户遇到"App 占用过大"、"切换账号后旧账号残留"、"创作草稿一直清不掉"、"活动进度缓存过期"等场景时,**没有统一的清理入口**。
- 当前只能在登出时被动清一部分(`store/modules/user.js#CLEAR_AUTH`),普通用户无法主动触发。
- 多个 utils 各自实现清理逻辑(`avatarCache.js`、`likeHelper.js`、`preloadApi/storage.js`、`ioPath.js`),但**没有一个聚合的 UI 入口**告知用户"我占了多少、可以清哪些"。
**技术问题**
- 清理逻辑分散在 7+ 个文件UI 层如果要"一键清理"必须串联调用 7 个函数,缺乏统一抽象。
- 新增缓存类型时需要改 UI如未来加 `eventCache`),违反"加 handler 不动 UI"的原则。
- 没有 size 计算的统一口径:部分用 `JSON.stringify(v).length`,部分用 `uni.getStorageInfoSync().currentSize`,用户看到的"总占用"无来源解释。
### 整体实现路径MVP 阶段1 周可落地)
| Milestone | 主题 | 目标 | 工作量 |
|-----------|------|------|--------|
| **M0**(前置迁移) | 草稿 key 改造 | `utils/draftStorage.js` 封装 + 7 个文件读写改造§11 | 1.5 天 |
| **M1** | 核心封装层 | `cacheManager.js` + 黑名单 + 大小格式化 + `getCacheInfo` / `getCategoryBreakdown` / `cleanCategory` / `cleanCategoryGroup` 框架 | 0.5 天 |
| **M2** | 6 个 handler | preload / draft / progress / guide / sandbox-tmp / others + 内存层清理接入 | 1 天 |
| **M3** | UI 页面(列表 + 详情) | `cache-cleanup.vue` + `cache-cleanup-detail.vue` + ConfirmModal + profile.vue 入口 + pages.json | 1.5 天 |
| **M4** | 测试 & 回归 | 单元测试 + 7 个手工场景(含跨账号分组清理)+ CLAUDE.md 自检 | 0.5 天 |
合计:约 **5 人天**(含 M0 前置迁移 + 详情页)。
### 关键决策
1. **统一封装层模式(方案 A**——新建 `frontend/utils/cacheManager.js`UI 只调 manager**永远不直接**调 `uni.removeStorageSync` 等底层 API。这与项目里 `frontend/utils/preloadApi/core.js` 已采用的封装模式一致。
2. **必须有二级详情页(用户决策)**——MVP 阶段**没有一键清理按钮**,每类缓存必须进详情页才能清理。理由:
- 草稿draft和引导guide天然有"我的 / 其他用户的"分组(一台设备多账号切换常见),一键清理会把前任账号数据也清掉,体验突兀
- 用户进入详情页可以看到每个分组的占用与项数,**明确知道清的是什么**
- 清理"其他用户的草稿"需要单独更强的警告(避免误操作),而清理"自己的草稿"只需要普通警告
- 其他分类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 不纳入清理,仅作黑名单保护**。
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 且强警告无法生效)。
### 核心架构图TL;DR
```
┌──────────────────────────────────────────┐
│ pages/profile/cache-cleanup.vue │ ← 列表页(无清理按钮,只跳转)
│ - 顶部:大字 + 百分比 + 进度条 + 3 chip │
│ - 缓存分类(每行 → 进详情) │
│ - "其他" section不可点击
└──────────────┬───────────────────────────┘
│ uni.navigateTo
┌──────────────▼───────────────────────────┐
│ pages/profile/cache-cleanup-detail.vue │ ← 详情页(按 id 分发)
│ - 简单页:单按钮 │
│ - 分组页:按 uid 分组卡片 + 每组单按钮 │
└──────────────┬───────────────────────────┘
│ 调用
┌──────────────▼───────────────────────────┐
│ utils/cacheManager.js │ ← 统一封装层(核心)
│ - registerCategory(handler) │
│ - getCacheInfo() │
│ - getCategoryBreakdown(id) │
│ - cleanCategory(id) / cleanCategoryGroup(id, {uid}) │
│ - cleanAll() — 编程式入口UI 不调用 │
│ - PROTECTED_KEYS内置黑名单
└──────────────┬───────────────────────┘
│ 委托
┌───────────┼─────────────┬─────────────────────┐
▼ ▼ ▼ ▼
preload draftHandler sandboxTmp 各种 handler
Handler (含警告) Handler (基于 (每个独立模块)
(基于 ioPath.js)
preloadApi)
```
---
## 二、文档说明
- **适用范围**`frontend/` 全部业务代码(不含 `uni_modules/` 第三方模块uni-app app-plus 端为主H5/小程序兼容性按现有项目规范。
- **工作量估算**5 人天MVP包含列表页 + 详情页 + 跨账号分组清理)。
- **前置版本**:基线 commit `ea39ee1`feat:修改图片尺寸和uni配置
- **目标读者**:前端工程师、测试、产品。
- **后续优化(不在 MVP**
- ~~二级详情页~~MVP 包含)
- 头像/图片缓存清理(需评估用户体验)
- 清理历史记录(用户看到"上次清理于 X 分钟前"
- 自动清理策略(按 LRU 自动触发)
---
## 三、Handler 接口契约
### 3.1 类型定义
```js
/**
* @typedef {Object} CacheCategoryHandler
* @property {string} id // 唯一 ID如 'preload' / 'draft' / 'sandbox-tmp'
* @property {string} label // UI 展示名(中文)
* @property {string} description // 副标题/说明(可选)
* @property {() => Promise<{sizeBytes: number, keyCount: number}>} computeSize
* // 列表页汇总展示用
* @property {() => Promise<Array<GroupInfo>>} [computeBreakdown]
* // 详情页按 uid 分组draft/guide 用,其他可选)
* @property {() => Promise<{freedBytes: number, keyCount: number}>} [clean]
* // 简单页清理(无分组):清理整类
* @property {(params: {uid: string|null}) => Promise<{freedBytes: number, keyCount: number}>} [cleanGroup]
* // 分组页清理:按 uid 维度清理uid=null 表示"其他用户"
* @property {boolean} [warning] // 是否需要"草稿会丢失"警告
* @property {string} [warningText] // 警告文案(如 draft: "将清空你未提交的创作草稿,是否继续?"
* @property {boolean} [strongWarning] // 是否需要强警告(用于清理他人数据)
* @property {string} [strongWarningText] // 强警告文案(如 draft-other: "将清空 uid 10002 的草稿,对方下次登录不会看到,是否继续?"
*/
/**
* @typedef {Object} GroupInfo
* @property {string} uid // 'self' = currentUid'others' = 其他用户聚合,'__legacy__' = 老无 uid 后缀 key其他 = 具体 uid
* @property {string} displayUid // UI 展示的 uid 字符串(如 "10001"
* @property {boolean} isCurrent // 是否当前用户
* @property {number} sizeBytes
* @property {number} keyCount
* @property {boolean} [canClean] // 该分组是否可清理handler 决定UI 据此 enable/disable 按钮)
* @property {string} [disabledReason] // 不可清理的原因('empty' / 'logged-out' / 'guide-current' / 'guide-global' / 'draft-legacy'
*/
```
**Handler 接口选择规则**
- **简单 handler**preload / progress / sandbox-tmp / others实现 `computeSize` + `clean`,不实现 `computeBreakdown`/`cleanGroup`
- **分组 handler**draft / guide实现 `computeSize` + `computeBreakdown` + `cleanGroup`;不实现 `clean`(分组页不提供"一键清整类"按钮,避免误清他人数据)
### 3.2 内置 Handler 列表
| Handler ID | label | description | warning | 说明 |
|----------|------|-------------|---------|------|
| `preload` | "预加载数据缓存" | "列表页提前拉取的数据" | false | `preloadApi/storage.js` 的所有 `preload:*` key |
| `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` 白名单) |
| `others` | "其他业务缓存" | "未归类的少量业务数据" | false | 兜底分类:未匹配上述任一规则**且不在黑名单**的 key 汇总。**不**包含草稿 keys任何 uid 后缀 + legacy那些全部归 `draft` handler。包含需保护的工作流关键 key`generation_flow_payload`、`__package_info__` 等)的**白名单排除**——这些 key 即使不在黑名单也不归 others 清理(避免破坏进行中的生成流程 / 升级包)。 |
### 3.3 cacheManager 公共 API
```js
// 注册(通常在 manager 模块底部 import 时一次性注册)
cacheManager.registerCategory(handler)
// 列表页读取(汇总 + 存储配额 + 其他 section 数据)
const info = await cacheManager.getCacheInfo()
// → {
// totalBytes, // 可清理总量(=所有 category.sizeBytes 之和,不含黑名单)
// appUsedBytes, // 软件占用WeChat 口径,包含黑名单)= uni.getStorageInfoSync().currentSize*1024 + 沙盒文件
// // 注currentSize 是 SQLite 全量(含黑名单),这是有意的 —— 表达"app 真实占用"而非"可清理"
// quotaTotalBytes, // 配额总量 = uni.getStorageInfoSync().limitSize*1024
// quotaAvailableBytes, // 配额可用 = quotaTotalBytes - appUsedBytes
// usagePercent, // 百分比0-1001 位小数)= appUsedBytes / quotaTotalBytes * 100
// othersBytes, // "其他" section 大小 = 所有黑名单 keys + 不可清理文件大小
// categories: [{id, label, description, sizeBytes, keyCount, warning}, ...]
// }
// 详情页读取(分组详情;简单 handler 返回 null
const groups = await cacheManager.getCategoryBreakdown(id)
// → Array<GroupInfo> | null
// 简单页清理(无分组维度)
await cacheManager.cleanCategory(id)
// → { freedBytes, keyCount }
// 分组页清理(按 uid 维度uid=null 表示其他用户聚合)
await cacheManager.cleanCategoryGroup(id, { uid: '10001' })
// → { freedBytes, keyCount }
// 编程式一键清理UI 不调用;供登出流程/测试用,按 handler 顺序依次执行)
await cacheManager.cleanAll()
// → { freedBytes, perCategory: [{id, freedBytes, keyCount, error?}, ...] }
```
### 3.4 关键设计决策
#### 3.4.1 永远不清的 key黑名单由 manager 内置)
**精确匹配(全等字符串)**
- `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_` —— 每日登录打卡
- `avatar_file_` —— 头像文件元数据(`avatarCache.js`,与 `uni.saveFile` 配对MVP 不清理 `uni.saveFile` 的 savedFilePath由 OS 缓存淘汰负责)
- `liked_assets_exhibition` —— 用户点赞记录(点赞是用户行为,不是临时缓存)
- `temp_register_` —— 注册中状态(**含 `temp_register_password` 明文密码**)。原因:用户注册途中点清理会导致注册流程不可恢复;密码明文也不应在 cache 层有任何存活窗口风险。
不论怎么点这些 key **绝对不会被清理**,保证:
1. 不会误踢登录token/user/star_id/cid
2. 不会误清点赞(用户主动行为,有业务价值)
3. 不会误清头像文件(避免全部重新下载,**这是 MVP 范围外的关键回归点**
4. 不会误清注册中状态导致注册流程断掉 + 避免密码明文暴露风险
#### 3.4.2 引导记录guide handler保留规则
- **当前用户判定**handler 启动时 `uni.getStorageSync('user')`parse 出 `uid`。**取不到(未登录)则视为无当前用户**。
- **保留规则****登录态与未登录态一致**
- `guide_*_${currentUid}_*`(当前用户的引导进度)→ 全部保留
- 无 userId 段的 `guide_*``guide_shown_${configKey}`、`guide_debug_mode`、`guide_first_show`)→ **全部保留**(与登录态无关)
- 其他用户的 `guide_done_${otherUid}_*` / `guide_step_${otherUid}_*` / `guide_rewards_claimed_${otherUid}` / `guide_completed_steps_${otherUid}_*` → 仅登录态可清理(**未登录态禁止清理**,按钮置灰 + tooltip"请先登录"
- **未登录态**currentUid = null全部 `guide_*_${anyUid}_*` 都视作"其他用户"(按"其他用户的引导记录"清理规则处理,但**未登录时不可清理**);无 userId 段 `guide_*` 仍保留。
- **owner 段识别**:支持数字 uid`guide_done_10001_*`)和 `default``guide_done_default_*`guideConfig.js 在未登录场景下使用)两种。
- 实现方式handler 拿到 currentUid 后,分类处理 —— 含 currentUid 段的跳过;无 userId 段但属于 `guide_shown_*`/`guide_debug_mode`/`guide_first_show` 的跳过;其余 `guide_*` 仅在登录态下可清理。
#### 3.4.3 分类分区规则(避免 others 误吞)
每个 key 的归属判定 **必须按以下优先级**
1. 黑名单前缀§3.4.1)→ **永不删**(不计入 totalBytes不计入"其他"section仅用于计算 `othersBytes`
2. `preload:` 前缀 → `preload` handler
3. 创作草稿白名单§3.2 draft`draft` handler
4. `progress_` 前缀 → `progress` handler
5. `guide_` 前缀 → `guide` handler含 §3.4.2 的保留判断)
6. 其余非黑名单 key → `others` handler
**关键**§5.1 的 `getCacheInfo()` 必须先过滤黑名单 key再分类计算。否则黑名单 key 既不会展示也不会被删,但会被错误计入 totalBytes 误导用户。
#### 3.4.4 内存层清理(详情页 + cleanAll 同步触发)
`preloadApi/core.js``memoryMap` 是进程级单例 Map —— **所有用户的 preload 数据都在同一个 Map 里**。切账号不清会导致老用户条目挤占内存。
**`cacheManager.cleanAll()`** 在 storage/sandbox handler 全部完成后,**同步**调:
```js
import { invalidateAll } from '@/utils/preloadApi/core'
// ...
await invalidateAll() // 清空 memoryMap + inFlightMap
```
**详情页 `cleanCategory('preload')` / `cleanCategoryGroup('preload', ...)`** 完成后,**同样**同步调 `invalidateAll()`。原因:用户从详情页清 preload 后,下一次进列表页如果 preload handler 仍命中 `memoryMap` 老数据,用户感知不到清理效果。
注意:`invalidateAll()` 不动 storage只清内存层。storage 清理由 `preload.clean()` / `preload.cleanGroup()`§5.2)负责。
#### 3.4.5 活跃创作页面保护MVP 不做)
用户编辑中点清理导致草稿丢失,仅靠 UI ConfirmModal 文案警示不做活跃检测YAGNI。后续优化可加页面级 dirty 标记。
---
## 四、UI 页面结构
### 4.1 入口
`pages/profile/profile.vue` 加菜单项「存储空间」点击进入 `cache-cleanup` 列表页。
### 4.2 列表页pages/profile/cache-cleanup.vue
**采用 WeChat 风格:顶部大字 + 百分比 + 进度条 + 一行 3 标签 + 缓存分类 + 其他 section**。**没有任何清理按钮**。
```
┌─────────────────────────────────────────────┐
│ ← 返回 存储空间 │ ← navbar
├─────────────────────────────────────────────┤
│ │
│ Topfans 已用空间 │ ← 小字 label
│ │
│ 49.7 MB │ ← 大字号主指标(主色)
│ │
│ 占据配额 16% 存储空间 │ ← 百分比副文字(次要色)
│ │
│ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░ 16% │ ← 进度条(当前用量占配额比例)
│ │
│ ┌────────────┬─────────────┬────────────┐ │
│ │ Topfans 已用空间 │ 内存缓存空间 │ 总内存空间 │ │ ← 一行 3 个 chip 标签(无数值)
│ └────────────┴─────────────┴────────────┘ │ 数值已分别显示在:大字 / 缓存分类 sum / 总配额
├─────────────────────────────────────────────┤
│ 缓存分类 │ ← section 标题(次要色)
├─────────────────────────────────────────────┤
│ 预加载数据缓存 > │ ← 分类行(点击进详情)
│ 15.2 MB · 142 项 │
├─────────────────────────────────────────────┤
│ 创作中的草稿 > │
│ 2.4 MB · 3 项 ⚠ 按账号分组清理 │
├─────────────────────────────────────────────┤
│ 活动进度缓存 > │
│ 0.4 MB · 5 项 │
├─────────────────────────────────────────────┤
│ 引导记录 > │
│ 0.2 MB · 8 项 │
├─────────────────────────────────────────────┤
│ 临时文件 > │
│ 1.5 MB · 15 项 │
├─────────────────────────────────────────────┤
│ 其他业务缓存 > │
│ 0.7 MB · 12 项 │
├─────────────────────────────────────────────┤
│ 其他 > │ ← 新增:黑名单/不可清理数据
│ 4.1 MB │
│ 包含运行 Topfans 的必要数据、账号会话、 │
│ 其他账号的数据等 │
└─────────────────────────────────────────────┘
```
**3 个标签的语义对应**(与 WeChat 同款:"微信已用空间/磁盘已用空间/磁盘可用空间"
- **Topfans 已用空间** = 大字 `appUsedBytes`(顶部已显示)
- **内存缓存空间** = 下方"缓存分类"的总和 = `totalBytes`(可清理部分)
- **总内存空间** = `uni.getStorageInfoSync().limitSize` × 1024 = `quotaTotalBytes`(配额上限)
3 个标签**无数据值**(数据值已分别在:大字、缓存分类、总配额三处展示),纯起"标签导航"作用。
**"其他" section 的来源**
- 数据 = 黑名单 keys 大小 + 黑名单沙盒文件大小(**所有不该/不能清理的数据**
- 包括登录会话access_token/user/star_id/cid/deviceFp 等、设备状态needs_welcome/has_seen_welcome/is_new_user/daily_login_completed_*、用户行为liked_assets_exhibition、头像缓存avatar_file_* + savedFilePath、注册中状态temp_register_*)、其他账号的草稿/引导残留等
- 文案(仿 WeChat"包含运行 Topfans 的必要数据、账号会话、其他账号的数据等"
- 该行**不可点击进入详情**(没有可清理内容)
**关于"磁盘" vs "配额"**
- MVP 用 `uni.getStorageInfoSync().limitSize`app 自己的 SQLite 配额),文案写"配额"而非"磁盘",不误导用户
- 设备级"磁盘总空间/可用空间"需要 native pluginiOS `NSFileManager` / Android `StatFs`**MVP 不引入**列为后续优化§2
**草稿/引导的二级提示**:行右侧副文字"按账号分组清理",警示用户详情页会有分组。
### 4.3 详情页pages/profile/cache-cleanup-detail.vue?id=xxx
**通用结构**:每个分类的详情页头部展示分类说明,中间展示分组(按需),底部展示清理按钮。
#### 4.3.1 简单详情页preload / progress / sandbox-tmp / others
```
┌─────────────────────────────────────────────┐
│ ← 返回 预加载数据缓存 │
├─────────────────────────────────────────────┤
│ 列表页提前拉取的数据 │ ← description
├─────────────────────────────────────────────┤
│ │
│ 占用 18.2 MB │ ← 大字号
│ 共 142 项缓存 │
│ │
├─────────────────────────────────────────────┤
│ │
│ [ 清 理 缓 存 ] │ ← 主按钮
│ │
└─────────────────────────────────────────────┘
```
点击「清理缓存」→ ConfirmModal带 warning 时显示警告)→ 确认 → 执行 → toast + 返回列表页(自动刷新)。
#### 4.3.2 分组详情页draft / guide
**按 uid 分组展示,每组独立清理按钮**
```
┌─────────────────────────────────────────────┐
│ ← 返回 创作中的草稿 │
├─────────────────────────────────────────────┤
│ 未提交的创作表单/生成结果 │
├─────────────────────────────────────────────┤
│ │
│ 👤 我的草稿uid: 10001
│ 1.2 MB · 2 项 │
│ [ 清 理 ] │
│ ───────────────────────────────── │
│ 👤 其他用户的草稿uid: 10002
│ 0.8 MB · 1 项 │
│ [ 清 理 ] ⚠ │ ← 更强警告
│ ───────────────────────────────── │
│ 👤 其他用户的草稿uid: 10003
│ 0.4 MB · 0 项 │ ← 0 项时按钮置灰
│ [ 清 理 ] │
│ │
└─────────────────────────────────────────────┘
```
**关键行为**
- 「我的草稿」按钮 → ConfirmModal 普通警告("将清空你未提交的草稿"
- 「其他用户的草稿」按钮 → ConfirmModal **更强警告**"将清空 uid 10002 的草稿,对方下次登录不会看到。是否继续?"
- 0 项分组的按钮置灰禁用
- 列表按 `sizeBytes` 降序排序,大占用在前
**引导详情页同结构**,但 description 与分组标题文案不同。
### 4.4 交互流程
1. **列表页 onLoad**:调 `cacheManager.getCacheInfo()` → 渲染分类列表(带 loading skeleton
2. **点击分类行**`uni.navigateTo` 进 `cache-cleanup-detail?id=xxx`
3. **详情页 onLoad**:调 `cacheManager.getCategoryBreakdown(id)`(简单 handler 返回 `null`UI 走简单页模板;分组 handler 返回 `Array<GroupInfo>`UI 走分组页模板)
4. **详情页点清理按钮**(分组页每组一个,简单页一个):弹 ConfirmModal按需 warning/strongWarning→ 确认 →
- 简单页:调 `cacheManager.cleanCategory(id)`
- 分组页:调 `cacheManager.cleanCategoryGroup(id, { uid: 'self' | '具体uid' | null })`uid='self' 表示当前用户uid=null 表示"其他用户聚合"
-`uni.showLoading` → toast → 返回列表页(`uni.navigateBack`,列表页 `onShow` 自动重算 `getCacheInfo`
5. **下拉刷新**列表页onPullDownRefresh 重新调 `getCacheInfo()` 刷新(页面级 enablePullDownRefresh
6. **下拉刷新**(详情页):重新调 `cacheManager.getCategoryBreakdown(id)`
### 4.5 视觉规范
- 复用项目已有 `components/ConfirmModal.vue`
- 分类行用 `pages/components/Header.vue` 同款 row 风格
- 警示文字用 `#FAAD14`(项目里的 warning 色);强警告用 `#FF4D4F`
- 分组卡片用 `border-radius: 12rpx` 圆角,与列表项视觉区分
---
## 五、数据流与加载策略
### 5.1 首次加载
```
pages/profile/cache-cleanup.vue onLoad
cacheManager.getCacheInfo()
├─ [并行 1] 遍历所有注册 handler 调 computeSize() Promise.all
│ ├─ preload.computeSize → 遍历所有 preload:* key
│ ├─ draft.computeSize → 遍历白名单内 `*_${currentUid}` key详见 §3.2 实现说明)
│ ├─ progress.computeSize → 遍历 progress_*
│ ├─ guide.computeSize → 遍历 guide_*
│ ├─ sandbox-tmp.computeSize → 调用 ioPath.scanSandboxTmpFiles()(新增只读 API返回 tmp 文件大小总和)
│ └─ others.computeSize → 上述未匹配的 key 汇总
├─ [并行 2] 读取存储配额
│ ├─ uni.getStorageInfoSync() → { currentSize (KB), limitSize (KB) }
│ └─ ioPath.getSandboxTotalSize() → 沙盒 doc 目录下所有文件总字节数(新增 ioPath 只读 API与 scanSandboxTmpFiles 不同,前者含白名单 preload/share/image 所有文件,后者仅统计 tmp/
聚合:
totalBytes = sum(categories.sizeBytes) // 可清理总量(=内存缓存空间 chip
appUsedBytes = currentSize * 1024 + sandboxBytes // 软件占用(大字 + Topfans已用空间 chip
// 注currentSize 是 uni storage 全量(含黑名单),这是 WeChat "已用空间" 口径
quotaTotalBytes = limitSize * 1024 // 配额总量(=总内存空间 chip
quotaAvailableBytes = quotaTotalBytes - appUsedBytes // 配额可用
usagePercent = (appUsedBytes / quotaTotalBytes) * 100 // 百分比
othersBytes = sum(黑名单 keys size) + 不可清理文件大小 // "其他" section
渲染页面(大字 + 百分比 + 进度条 + 3 chip 标签 + 6 缓存分类行 + 1 其他 section
```
### 5.2 清理流程
**用户从详情页清理(主要路径)**
```
用户在详情页点清理按钮(简单页单按钮 / 分组页每组按钮)
ConfirmModal 显示预估 freedBytes含 warning/strongWarning 时显示对应警告文案)
用户确认
cacheManager.cleanCategory(id) ← 简单页
cacheManager.cleanCategoryGroup(id, {uid}) ← 分组页uid='self' 或具体 uid 或 null=其他用户聚合)
├─ 调对应 handler.clean() 或 handler.cleanGroup()
├─ 错误容错(单 handler 失败不影响)
返回 { freedBytes, keyCount }
UI toast + uni.navigateBack列表页 onShow 自动重算 getCacheInfo
```
**编程式 cleanAll登出/测试用UI 不调用)**
```
cacheManager.cleanAll()
├─ 顺序执行每个 handler.clean()(非 Promise.all更安全
│ ├─ preload.clean → 遍历所有 `preload:` 前缀 key 删除(**所有用户**,不限于 currentUid详见 §1 决策 6
│ ├─ draft.clean → 删除 `*_${currentUid}` 后缀的草稿 keyuid 改造完成后;详见 §11迁移期内老无 uid 后缀 key 归 others
│ ├─ progress.clean → 删除 progress_* key
│ ├─ guide.clean → 删除其他用户的 `guide_*_${otherUid}_*` + 当前用户的 `guide_done_*`/`guide_step_*`/...(详见 §3.4.2
│ ├─ sandbox-tmp.clean → clearAllSandboxTmpFiles()
│ └─ others.clean → 删除未分类 key含迁移期遗留的无 uid 后缀草稿 key
├─ ★ 同步清理内存层core.invalidateAll()(详见 §3.4.4
返回 { freedBytes, perCategory[] }
```
### 5.3 关键设计决策
1. **getCacheInfo 异步并行**6 个 handler 用 `Promise.all` 并行计算
2. **cleanAll 顺序执行**:部分清理会互斥,顺序更安全;用 `uni.showLoading({mask:true})` 包裹
3. **失败容错**:单个 handler clean() 失败不影响其他 handler外层 try/catch 记录 error 并跳过
4. **空状态**:若 totalBytes = 0列表页显示 "✓ 当前无缓存可清理"所有分类行点击无响应toast 提示"暂无缓存"
5. **重新计算时机**:清理完成后**必须**重算 `getCacheInfo()`,不能用 `info.totalBytes - freedBytes` 简单相减
6. **cleanCategory/cleanCategoryGroup 单例保护**`cleanCategory(id)` 与 `cleanCategoryGroup(id, opts)` 各维护一个 `_inFlight` Mapkey 为 `id``${id}#${uid}`);并发触发同一目标返回同一 Promise不重复执行。UI 通过 `uni.showLoading({mask:true})` 阻隔误触。`cleanAll()` 不再被 UI 调用,保留作为登出流程/测试的编程式入口,不需 in-flight 保护。
---
## 六、错误处理 & 边界条件
### 6.1 错误分类与处理
| 场景 | 行为 |
|------|------|
| 单个 handler `computeSize()` 抛错 | 该分类显示 `sizeBytes: -1`、label 旁加 `?` 标记主页面其他分类正常显示console.warn 记录 |
| 单个 handler `clean()` / `cleanGroup()` 抛错 | 该分类跳过perCategory 中标记 `{id, freedBytes: 0, error: 'msg'}`,其他继续;最终 toast 显示「已清理 XX MBX 项失败)」 |
| `getCacheInfo()` 整体超时(>5s| 兜底显示空列表 + 「加载失败,点此重试」按钮 |
| `uni.getStorageInfoSync()` 失败(罕见)| 整个 storage 维度显示 `— MB`,仅沙盒维度可清理 |
| 沙盒文件不存在(登出后清理等场景)| `clearAllSandboxTmpFiles` 已实现"目录不存在视为成功",无需额外处理 |
### 6.2 边界条件
- **页面打开瞬间用户被踢登录**`getCacheInfo()` 不依赖 token可正常返回若清理中触发退出回调里捕错即可
- **清理进行中用户退出页面**:用 `cacheManager.cleanCategory()` 的 in-flight 单例保护 —— 重复触发返回同一 Promise
- **草稿清理有未提交内容**UI 在 ConfirmModal 文案写明"将清空你未提交的创作草稿"不做活跃页面编辑检测YAGNI
- **黑名单 key 误触**:内置黑名单在 `cleanCategory` / `cleanCategoryGroup` 入口拦截,永不删
- **大小格式化**:用 `KB/MB/GB` 自适应1 位小数(避免 `0.0 MB`< 1KB 显示 `< 1 KB`
- **顶部数据加载失败**配额读取失败罕见UI 显示"配额数据暂不可用"分类列表仍正常渲染getCacheInfo() 内部 try/catch 单点失败不影响其他字段
- **详情页无 uid 数据**草稿/引导详情页若当前用户 uid 取不到未登录全部归为"其他用户"分组**不允许清理**按钮置灰 + tooltip"请先登录"
- **未登录态只能清理简单页**preload / progress / sandbox-tmp / others 在未登录态允许清理draft / guide 在未登录态**禁止清理**按钮置灰
### 6.3 关键日志
所有 handler 失败必须 `console.warn('[cacheManager] xxx failed:', id, err.message)`便于线上排查
---
## 七、测试策略
### 7.1 单元测试
- `utils/cacheManager.js` 纯函数黑名单匹配key 分类大小格式化GroupInfo 构造单测
- 6 handler `computeSize` / `clean` / `computeBreakdown` / `cleanGroup` 单测mock `uni.getStorageSync`
- `draftStorage.js` 双读单写逻辑单测mock storage
### 7.2 集成测试
mock `uni.getStorageSync` / `clearAllSandboxTmpFiles` / `core.invalidateAll`验证
- `cleanAll()` 顺序执行 + invalidateAll 同步
- 失败容错单个失败不影响其他
- 黑名单永不删
- `cleanCategory('preload')` 完成后 memoryMap 已清空
### 7.3 手工验证场景
| 场景 | 期望 |
|------|------|
| 登录态清理简单页 | token/user/star_id/cid 不变console 二次确认 |
| 登录态清理分组页-我的草稿 | 仅删 currentUid 草稿普通警告 |
| 登录态清理分组页-其他用户 | 仅删 otherUid 草稿强警告对方下次登录看不到 |
| 未登录态清理简单页 | 全部业务缓存正常清理 |
| 未登录态清理分组页 | 草稿/引导按钮置灰不可清理 |
| 清理中退出页面 | 不重复清理不报错 |
| 空缓存状态 | 列表页显示"✓ 当前无缓存可清理"无跳转入口 |
| 草稿详情页仅自己 | 仅显示"我的草稿"分组清理按钮触发普通警告 |
| 草稿详情页多账号切换后 | 显示"我的草稿" + 多个"其他用户的草稿"分组强警告+正常清理流程 |
| 草稿详情页未登录 | 所有数据归"其他用户"按钮置灰 |
| **账号 A 写草稿 → 退出 → 账号 B 登录 → 打开创作页** | **B 看不到 A 的草稿**依赖 §11 改造 |
| **账号 A 产生 preload 缓存 → 退出 → 账号 B 登录 → B 进 preload 详情页清理** | **A 的 preload 也被清掉**(§1 决策 6 |
| **清理 preload 后立刻再访问同一列表页** | **不会从 memoryMap 命中老数据**(§3.4.4 内存层清理 |
---
## 八、文件清单
### 8.1 新增文件
| 路径 | 说明 |
|------|------|
| `frontend/utils/cacheManager.js` | 统一封装层核心 250-300 |
| `frontend/utils/handlers/preloadHandler.js` | preload 缓存 handler简单型 |
| `frontend/utils/handlers/draftHandler.js` | 创作草稿 handler分组型 computeBreakdown + cleanGroup |
| `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/othersHandler.js` | 兜底分类 handler简单型 |
| `frontend/pages/profile/cache-cleanup.vue` | 缓存清理列表页只展示无按钮 |
| `frontend/pages/profile/cache-cleanup-detail.vue` | 缓存清理详情页 `id` 分发到简单/分组模板 |
### 8.2 修改文件
| 路径 | 改动 |
|------|------|
| `frontend/pages/profile/profile.vue` | 加菜单项存储空间点击进入 `cache-cleanup` |
| `frontend/pages.json` | 注册两个新页面cache-cleanup 列表 + cache-cleanup-detail 详情 |
| `frontend/utils/ioPath.js` | 新增 `getSandboxTotalSize()` 只读 API(§5.1 并行 2 用于计算沙盒文件总字节|
### 8.3 实施顺序5 个 milestoneM0 是 M2 前置)
1. **M0 — 草稿 key 改造(前置迁移)**`utils/draftStorage.js` 封装 + 7 个文件读写侧改造 + 双读单写兼容(§11
2. **M1 — 核心封装层**`cacheManager.js` + 黑名单 + 大小格式化 + `getCacheInfo()` / `getCategoryBreakdown()` / `cleanCategory()` / `cleanCategoryGroup()` 框架handlers 暂时用空 stub包含 in-flight 单例保护
3. **M2 — 6 个 handler 实现**每个 handler computeSize / clean computeBreakdown + cleanGrouppreload 跨用户清理preload handler 清理完成后同步调 `invalidateAll()`
4. **M3 — UI 页面(列表 + 详情)**`cache-cleanup.vue`列表+ `cache-cleanup-detail.vue`简单/分组模板+ ConfirmModal + profile.vue 入口 + pages.json 注册
5. **M4 — 测试 & 回归**单元测试 + 13 个手工场景验证含跨账号分组清理未登录态分组禁用清理后内存即清等+ 对照 CLAUDE.md 完成自检清单
---
## 九、验收标准DoD
- [ ] 个人中心出现存储空间入口点击进入列表页
- [ ] 列表页顶部显示**大字Topfans 已用空间+ 百分比占据配额 X%+ 进度条 + 3 chip 标签无数值+ 6 个分类行 + 1 "其他"section**
- [ ] 列表页**没有任何清理按钮**点击分类行进入详情页"其他"section 不可点击
- [ ] **简单详情页**preload / progress / sandbox-tmp / others单按钮 + ConfirmModal warning 时显示警告文案
- [ ] **分组详情页**draft / guide uid 分组卡片每组独立清理按钮当前用户组普通警告他人组**强警告**0 项组按钮置灰
- [ ] 未登录态打开草稿/引导详情页所有数据归"其他用户"按钮置灰 + tooltip "请先登录"
- [ ] 详情页清理 preload 后立即进列表页memoryMap 已清空重新拉数据
- [ ] 详情页清理成功后 toast + 返回列表页onShow 自动重算
- [ ] 单个 handler 失败不影响其他 handler
- [ ] 登录 token用户信息设备指纹等黑名单 key **绝对不会被清理** console.log 二次确认
- [ ] `pages.json` 已注册两个新页面列表 + 详情`unpackage/dist/` 未手动改
- [ ] CLAUDE.md 自检清单API 工程化前端规范接口规范均过
---
## 十、风险与回滚
| 风险 | 影响 | 缓解 |
|------|------|------|
| 黑名单遗漏某个登录态 key | 用户被错误退出 | M2 完成后 grep 全量 storage key 与黑名单比对M4 手工验证登录态清理 |
| handler 顺序错误 | sandbox 清理在 preload 清理之前引用失效 | 已设计为顺序执行单测覆盖 |
| 草稿清理误删活跃数据 | 用户创作内容丢失 | ConfirmModal 文案警示后续可加活跃检测MVP 不做 |
| `clearAllSandboxTmpFiles` 性能 | 启动时已实现启动清理通常 0 tmp | 实测 50ms 内完成超过 1s loading |
回滚删除 `pages/profile/cache-cleanup.vue` + `pages.json` 注册项 + profile.vue 菜单项即可业务无侵入
---
## 十一、草稿 key 改造迁移项
> **范围外工作但与本 spec 强耦合**:为了让 §3.2 的 draft handler 准确按 uid 分类,必须先把草稿 storage key 改为 `*_${uid}` 形式。本节作为 M2 启动前的**前置依赖**。
### 11.1 现状问题
当前草稿 key 不带 uid
- `castlove_form_data`
- `CASTLOVE_FORM_KEY`
- `temp_nft_data`
- `GENERATED_IMAGES_KEY`
- `GENERATION_RESULT_META_KEY`
- `LENTICULAR_STUDIO_STORAGE_KEY`
- `CRAFT_SELECTED_IMAGE_KEY`
**结果**账号 A 的草稿在设备上 退出登录 B 账号登录 打开创作页 **看到 A 的草稿**这是隐私 + 体验问题
### 11.2 改造方案
将所有写入侧`pages/castlove/create.vue`、`pages/castlove/index.vue`、`pages/castlove/success.vue`、`composables/useLaserSegment.js`、`composables/useLaserBatchGenerate.js` 改为
```js
// 旧
uni.setStorageSync('castlove_form_data', JSON.stringify(formData))
// 新
uni.setStorageSync(`castlove_form_data_${currentUid}`, JSON.stringify(formData))
```
读取侧同样需改造
### 11.3 迁移策略
**双读单写**
- 先读新 key `*_${currentUid}`若不存在**fallback** 读老 key读到后将数据写到新 key key 删除
- 只写新 key `*_${currentUid}`
实现位置建议封装一个 `utils/draftStorage.js`所有读写都走它业务侧无感
### 11.4 与 cacheManager 的关系
- **迁移完成前**draft handler.computeSize() 同时计入新 key`*_${currentUid}`+ key uid 后缀所有都按 uid 分组无后缀 key `__legacy__` 分组
- **迁移完成后**draft handler 仅匹配 `*_${currentUid}` + 老无 uid 后缀 key理论上不应再有但万一有仍归 `__legacy__` 分组**** `others` 兜底
- **验证**M4 手工场景新增"切换账号后看不到对方草稿"
### 11.5 工作量估算
**M0 范围限定为「核心 castlove 创作流」(约 7 个文件)**
- `pages/castlove/create.vue`、`pages/castlove/index.vue`、`pages/castlove/success.vue`、`pages/castlove/lenticular/lenticular-result.vue`、`pages/castlove/laser/laser-result.vue`、`composables/useLaserSegment.js`、`composables/useLaserBatchGenerate.js`
- 包含`castlove_form_data` / `CASTLOVE_FORM_KEY` / `temp_nft_data` / `GENERATED_IMAGES_KEY` / `GENERATION_RESULT_META_KEY` / `LENTICULAR_STUDIO_STORAGE_KEY` / `CRAFT_SELECTED_IMAGE_KEY`
**discover / mint / self-created 等流程(约 10 个其他文件)**涉及相同 key 但属于次级路径,**列为 M0 后续批次M0.5**MVP 上线后补估算额外 1
**核心 castlove 7 文件改造工作量**
- 改造 7 个文件读写侧0.5
- `draftStorage.js` 封装0.5
- 联调验证0.5
合计 **1.5 天**作为 M2 启动前置
---
## 十二、自检清单
CLAUDE.md 要求
- [ ] 文档开头方案概述要解决的问题 / 实现路径 / 关键决策 / 核心架构图
- [ ] MVP 先行未引入超出当前业务需要的抽象统一封装层是必要的避免 UI 上帝化
- [ ] 文件清单与目录结构对齐`utils/handlers/` 是新目录需提前创建
- [ ] 跨章节引用一致性:§3.2 §4.2 分类一致;§4.3 §5 流程一致;§6 §7 测试场景一致
- [ ] CLAUDE.md 自检清单前端规范新组件用 `<script setup>` 组合式 API所有原生 API `#ifdef`接口走 `utils/api.js`新页面已在 `pages.json` 注册敏感权限失败有"去设置"出口不动 `unpackage/dist/`