From d5a08db56c85ab61797755874f6420ea22a12408 Mon Sep 17 00:00:00 2001 From: zheng020 Date: Wed, 29 Jul 2026 16:03:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E7=A9=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/2026-07-28-cache-cleanup-design.md | 680 +++++ .../plans/2026-07-28-cache-cleanup.md | 2351 +++++++++++++++++ frontend/composables/useLaserBatchGenerate.js | 4 +- frontend/composables/useLaserSegment.js | 18 +- frontend/manifest.json | 2 +- frontend/pages.json | 14 + frontend/pages/asset-detail/asset-detail.vue | 7 +- frontend/pages/castlove/create.vue | 9 +- frontend/pages/castlove/index.vue | 11 +- .../pages/castlove/laser/laser-result.vue | 15 +- .../castlove/lenticular/lenticular-create.vue | 2 +- .../castlove/lenticular/lenticular-result.vue | 18 +- .../lenticular/lenticular-thinking.vue | 4 +- frontend/pages/castlove/success.vue | 7 +- .../pages/discover/generation-loading.vue | 20 +- frontend/pages/discover/generation-result.vue | 18 +- .../pages/profile/cache-cleanup-detail.vue | 431 +++ frontend/pages/profile/cache-cleanup.vue | 257 ++ frontend/pages/profile/profile.vue | 8 + frontend/utils/cacheManager.js | 233 ++ frontend/utils/castloveGenerationFlow.js | 145 +- frontend/utils/craftMintSubmit.js | 10 +- frontend/utils/draftStorage.js | 127 + frontend/utils/handlers/draftHandler.js | 162 ++ frontend/utils/handlers/guideHandler.js | 138 + frontend/utils/handlers/guideKeys.js | 30 + frontend/utils/handlers/othersHandler.js | 79 + frontend/utils/handlers/preloadHandler.js | 51 + frontend/utils/handlers/progressHandler.js | 46 + frontend/utils/handlers/sandboxTmpHandler.js | 27 + frontend/utils/ioPath.js | 193 +- frontend/utils/preloadApi/core.js | 31 +- 32 files changed, 5045 insertions(+), 103 deletions(-) create mode 100644 docs/specs/2026-07-28-cache-cleanup-design.md create mode 100644 docs/superpowers/plans/2026-07-28-cache-cleanup.md create mode 100644 frontend/pages/profile/cache-cleanup-detail.vue create mode 100644 frontend/pages/profile/cache-cleanup.vue create mode 100644 frontend/utils/cacheManager.js create mode 100644 frontend/utils/draftStorage.js create mode 100644 frontend/utils/handlers/draftHandler.js create mode 100644 frontend/utils/handlers/guideHandler.js create mode 100644 frontend/utils/handlers/guideKeys.js create mode 100644 frontend/utils/handlers/othersHandler.js create mode 100644 frontend/utils/handlers/preloadHandler.js create mode 100644 frontend/utils/handlers/progressHandler.js create mode 100644 frontend/utils/handlers/sandboxTmpHandler.js diff --git a/docs/specs/2026-07-28-cache-cleanup-design.md b/docs/specs/2026-07-28-cache-cleanup-design.md new file mode 100644 index 0000000..e686b9c --- /dev/null +++ b/docs/specs/2026-07-28-cache-cleanup-design.md @@ -0,0 +1,680 @@ +# 缓存清理功能设计(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>} [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-100,1 位小数)= appUsedBytes / quotaTotalBytes * 100 +// othersBytes, // "其他" section 大小 = 所有黑名单 keys + 不可清理文件大小 +// categories: [{id, label, description, sizeBytes, keyCount, warning}, ...] +// } + +// 详情页读取(分组详情;简单 handler 返回 null) +const groups = await cacheManager.getCategoryBreakdown(id) +// → Array | 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 plugin(iOS `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`,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}` 后缀的草稿 key(uid 改造完成后;详见 §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` Map(key 为 `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 MB(X 项失败)」 | +| `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 个 milestone,M0 是 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 + cleanGroup);preload 跨用户清理;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 自检清单(前端规范):新组件用 ` + + +``` + +- [ ] **Step 3: 编译验证 + 实机查看** + +Run: HBuilderX → 运行 → 真机/模拟器 → 进入 cache-cleanup 页面 +预期:顶部大字显示、进度条显示、6 个分类行 + "其他" section 正常渲染 + +- [ ] **Step 4:(如用户授权)commit** + +```bash +git add frontend/pages/profile/cache-cleanup.vue frontend/pages.json +git commit -m "feat(ui): add cache-cleanup list page with WeChat-style top" +``` + +--- + +## Task 22: 创建 pages/profile/cache-cleanup-detail.vue 简单页模板 + +**Files:** +- Create: `frontend/pages/profile/cache-cleanup-detail.vue` + +- [ ] **Step 1: 在 pages.json 注册** + +```json +{ + "path": "pages/profile/cache-cleanup-detail", + "style": { + "navigationBarTitleText": "", + "enablePullDownRefresh": true + } +} +``` + +- [ ] **Step 2: 实现详情页(含简单/分组双模板)** + +```vue + + + + + +``` + +- [ ] **Step 3: 编译 + 实机验证两个模板** + +预期: +- 进入 preload / progress / sandbox-tmp / others → 简单页(单按钮) +- 进入 draft / guide → 分组页(多卡片 + 各自的清理按钮) + +- [ ] **Step 4:(如用户授权)commit** + +```bash +git add frontend/pages/profile/cache-cleanup-detail.vue frontend/pages.json +git commit -m "feat(ui): add cache-cleanup-detail page (simple + grouped templates)" +``` + +--- + +## Task 23: pages/profile/profile.vue 加「存储空间」菜单项 + +**Files:** +- Modify: `frontend/pages/profile/profile.vue` + +- [ ] **Step 1: 找到 settings/menu list 区域** + +查找 profile.vue 里的菜单项数组(如 `menuItems` 或类似的循环),加一项: + +```js +{ id: 'storage', icon: '💾', label: '存储空间', url: '/pages/profile/cache-cleanup' } +``` + +- [ ] **Step 2: 实机验证** + +进 profile 页面,能看到「存储空间」菜单项,点击进入 cache-cleanup 列表页正常显示。 + +- [ ] **Step 3:(如用户授权)commit** + +```bash +git add frontend/pages/profile/profile.vue +git commit -m "feat(profile): add '存储空间' menu item" +``` + +--- + +## Task 24: M3 联调(端到端) + +**Files:** +- 无(仅验证) + +- [ ] **Step 1: 端到端流程验证(已登录态)** + +1. 进 profile → 点「存储空间」→ cache-cleanup 列表页正常 +2. 大字 + 百分比 + 进度条 + 3 chip + 6 分类 + "其他" section 全部显示 +3. 点「预加载数据缓存」→ cache-cleanup-detail 简单页 → 点清理 → ConfirmModal → 确认 → loading → toast → 返回列表页自动刷新 +4. 大字 / 进度条数字更新 + +- [ ] **Step 2: 分组清理验证** + +1. 账号 A 登录,castlove 创作流写一些草稿(draftStorage 写入) +2. 退出账号 A,登录账号 B(uid 不同) +3. 进 cache-cleanup → 点「创作中的草稿」→ 分组页应显示 "我的草稿(uid=B)" + "其他用户的草稿(uid=A)" +4. 点 "其他用户的草稿" 卡片清理 → 强警告弹窗 → 确认 → toast +5. 返回列表页,"创作中的草稿" 大小应减少 + +- [ ] **Step 3: 未登录态分组页验证** + +1. 退出登录 +2. 进 cache-cleanup → 点「创作中的草稿」→ 分组页所有数据归"其他用户",按钮置灰 + +- [ ] **Step 4: 内存清理验证** + +1. 列表页进 preload 详情页 → 清理 → 立刻返回列表页 → 数字更新(说明 memoryMap 已清) +2. console 检查:用导出的 `getStats()`(不是 memoryMap 字段,reviewer P0 修正): + ```js + import('@/utils/preloadApi/core').then(c => console.log(c.getStats())) + // 期望: { hits: X, misses: Y, memorySize: 0, inFlightSize: 0, ... } + ``` + 清理后 `memorySize` 和 `inFlightSize` 都应为 0 + +- [ ] **Step 5:(如用户授权)commit** + +```bash +git add frontend/pages/profile/profile.vue frontend/pages/profile/cache-cleanup.vue frontend/pages/profile/cache-cleanup-detail.vue frontend/pages.json +git commit -m "feat(profile): add cache cleanup UI (list + detail pages)" +# E2E 验证在 PR description 中描述 +``` + +--- + +# Phase 5: M4 — 13 个手工场景回归 + +## Task 25: 完整场景回归清单 + +**Files:** +- 无(仅验证) + +- [ ] **Step 1: 13 个手工场景逐项验证** + +完整场景列表(与 spec §7.3 对应): + +| # | 场景 | 期望 | 验证 | +|---|------|------|------| +| 1 | 登录态清理简单页 | token/user/star_id/cid 不变 | ☐ | +| 2 | 登录态清理分组页-我的草稿 | 仅删 currentUid 草稿;普通警告 | ☐ | +| 3 | 登录态清理分组页-其他用户 | 仅删 otherUid 草稿;强警告;对方下次登录看不到 | ☐ | +| 4 | 未登录态清理简单页 | 全部业务缓存正常清理 | ☐ | +| 5 | 未登录态清理分组页 | 草稿/引导按钮置灰,不可清理 | ☐ | +| 6 | 清理中退出页面 | 不重复清理,不报错 | ☐ | +| 7 | 空缓存状态 | 列表页显示"✓ 当前无缓存可清理",无跳转入口 | ☐ | +| 8 | 草稿详情页(仅自己) | 仅显示"我的草稿"分组,清理按钮触发普通警告 | ☐ | +| 9 | 草稿详情页(多账号切换后) | 显示"我的草稿" + 多个"其他用户的草稿"分组;强警告+正常清理流程 | ☐ | +| 10 | 草稿详情页(未登录) | 所有数据归"其他用户",按钮置灰 | ☐ | +| 11 | 跨账号草稿隔离(A 写 → B 登录) | B 看不到 A 的草稿(依赖 §11 M0 改造) | ☐ | +| 12 | 跨账号 preload 清理 | A 的 preload 被 B 清理时一并清掉 | ☐ | +| 13 | 清理后立刻进列表页 | 不会从 memoryMap 命中老数据 | ☐ | + +- [ ] **Step 2: CLAUDE.md 自检清单过一遍** + +- [ ] 新组件用 ` + + \ No newline at end of file diff --git a/frontend/pages/profile/cache-cleanup.vue b/frontend/pages/profile/cache-cleanup.vue new file mode 100644 index 0000000..4da25a2 --- /dev/null +++ b/frontend/pages/profile/cache-cleanup.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/frontend/pages/profile/profile.vue b/frontend/pages/profile/profile.vue index f407582..d00622f 100644 --- a/frontend/pages/profile/profile.vue +++ b/frontend/pages/profile/profile.vue @@ -229,6 +229,10 @@ inboxUnread }} + + 💾 + 存储空间 + { console.log("[Profile] Guide updated"); diff --git a/frontend/utils/cacheManager.js b/frontend/utils/cacheManager.js new file mode 100644 index 0000000..b4ea034 --- /dev/null +++ b/frontend/utils/cacheManager.js @@ -0,0 +1,233 @@ +// frontend/utils/cacheManager.js +// 缓存清理统一封装层 +// 详见 docs/superpowers/specs/2026-07-28-cache-cleanup-design.md + +import { invalidateAll } from './preloadApi/core' +import sandboxTmpHandler from './handlers/sandboxTmpHandler' +import progressHandler from './handlers/progressHandler' +import othersHandler from './handlers/othersHandler' +import preloadHandler from './handlers/preloadHandler' +import guideHandler from './handlers/guideHandler' +import draftHandler from './handlers/draftHandler' + +// ── 黑名单(§3.4.1)── +// 全等匹配 +const PROTECTED_EXACT = new Set([ + 'access_token', 'user', 'star_id', 'login_mobile', 'cid', + 'deviceFp', 'pending_scan_url', 'gallery_owner_id', + 'needs_welcome', 'has_seen_welcome', 'is_new_user', + 'liked_assets_exhibition', +]) +// 前缀匹配 +const PROTECTED_PREFIX = [ + 'daily_login_completed_', + 'avatar_file_', + 'temp_register_', +] + +/** + * 判断 key 是否在黑名单中 + */ +export function isProtectedKey(key) { + if (typeof key !== 'string') return false + if (PROTECTED_EXACT.has(key)) return true + return PROTECTED_PREFIX.some((p) => key.startsWith(p)) +} + +// ── 大小格式化(§6.2)── +export function formatSize(bytes) { + if (typeof bytes !== 'number' || bytes < 0) return '—' + if (bytes < 1024) return '< 1 KB' + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb.toFixed(1)} MB` + return `${(mb / 1024).toFixed(2)} GB` +} + +// ── 内部状态 ── +const handlers = new Map() // handlerId → handler +const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise + +// ── 注册 API ── +export function registerCategory(handler) { + if (!handler?.id) throw new Error('[cacheManager] handler.id is required') + handlers.set(handler.id, handler) +} + +function getHandler(id) { + const h = handlers.get(id) + if (!h) throw new Error(`[cacheManager] unknown handler: ${id}`) + return h +} + +// ── 公共 API ── + +/** + * 列表页读取(汇总 + 存储配额 + 其他 section 数据) + */ +export async function getCacheInfo() { + // [并行 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 + 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 { + const v = uni.getStorageSync(k) + if (v != null) blacklistBytes += JSON.stringify(v).length + } catch (e) { /* skip */ } + } + } + } catch (e) { + console.warn('[cacheManager] getStorageInfoSync failed:', e.message) + } + try { + const { getSandboxTotalSize } = await import('./ioPath') + sandboxBytes = await getSandboxTotalSize() + } catch (e) { + console.warn('[cacheManager] getSandboxTotalSize failed:', e.message) + } + + const totalBytes = categories.reduce((sum, c) => sum + (c.sizeBytes > 0 ? c.sizeBytes : 0), 0) + const appUsedBytes = currentSizeKB * 1024 + sandboxBytes + 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 + + return { + totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent, + othersBytes: blacklistBytes + sandboxBytes, + categories, + } +} + +/** + * 详情页读取(分组详情;简单 handler 返回 null) + */ +export async function getCategoryBreakdown(id) { + const h = getHandler(id) + if (typeof h.computeBreakdown !== 'function') return null + try { + return await h.computeBreakdown() + } catch (e) { + console.warn(`[cacheManager] computeBreakdown failed: ${id}`, e.message) + return [] + } +} + +/** + * 简单页清理(无分组维度) + */ +export function cleanCategory(id) { + return _runWithInFlight(`${id}`, async () => { + const h = getHandler(id) + if (typeof h.clean !== 'function') { + throw new Error(`[cacheManager] handler ${id} has no clean() (use cleanCategoryGroup)`) + } + try { + const result = await h.clean() + // preload 清理同步清内存(§3.4.4) + if (id === 'preload') { + try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) } + } + return result + } catch (e) { + console.warn(`[cacheManager] clean failed: ${id}`, e.message) + throw e + } + }) +} + +/** + * 分组页清理(按 uid 维度) + * @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) + return _runWithInFlight(`${id}#${uidKey}`, async () => { + const h = getHandler(id) + if (typeof h.cleanGroup !== 'function') { + throw new Error(`[cacheManager] handler ${id} has no cleanGroup() (use cleanCategory)`) + } + try { + const result = await h.cleanGroup({ uid: opts.uid === undefined ? 'self' : opts.uid }) + if (id === 'preload') { + try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) } + } + return result + } catch (e) { + console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message) + throw e + } + }) +} + +/** + * 编程式清理所有(UI 不调用;登出流程/测试用) + * 对每个 handler 调用其支持的清理方法: + * - 优先 clean()(简单型) + * - 否则调 cleanGroup({ uid: 'self' })(仅清当前用户的分组数据,符合"登出前清自己"的语义) + */ +export async function cleanAll() { + const results = [] + for (const h of handlers.values()) { + try { + let r + if (typeof h.clean === 'function') { + r = await h.clean() + } else if (typeof h.cleanGroup === 'function') { + r = await h.cleanGroup({ uid: 'self' }) + } else { + continue + } + results.push({ id: h.id, ...r, error: null }) + } catch (e) { + console.warn(`[cacheManager] cleanAll failed for ${h.id}:`, e.message) + results.push({ id: h.id, freedBytes: 0, keyCount: 0, error: e.message }) + } + } + try { invalidateAll() } catch (e) { console.warn('[cacheManager] cleanAll invalidateAll failed:', e.message) } + return { freedBytes: results.reduce((s, r) => s + (r.freedBytes || 0), 0), perCategory: results } +} + +// NOTE: 必须非 async —— async 会把返回的 Promise 再包一层,破坏 +// `p1 === p2` 同一性断言(参见 plan §Task 12 Step 2 自测)。 +// 这里需要直接返回存储在 cleanInFlight 中的 Promise 引用本身。 +function _runWithInFlight(key, fn) { + if (cleanInFlight.has(key)) return cleanInFlight.get(key) + const p = (async () => { + try { return await fn() } finally { cleanInFlight.delete(key) } + })() + cleanInFlight.set(key, p) + return p +} + +// ── Handler 注册(模块加载时执行;新增 handler 在此追加 registerCategory 调用)── +registerCategory(sandboxTmpHandler) +registerCategory(progressHandler) +registerCategory(othersHandler) +registerCategory(preloadHandler) +registerCategory(guideHandler) +registerCategory(draftHandler) \ No newline at end of file diff --git a/frontend/utils/castloveGenerationFlow.js b/frontend/utils/castloveGenerationFlow.js index a3b2b8f..6b8e65c 100644 --- a/frontend/utils/castloveGenerationFlow.js +++ b/frontend/utils/castloveGenerationFlow.js @@ -5,7 +5,7 @@ * 支持四种模式:API生成、预填充选择、光栅卡流程(lenticular/*)、镭射工作室 */ -import { LENTICULAR_STUDIO_STORAGE_KEY } from '@/utils/castloveMintForm.js' +import { writeDraft } from '@/utils/draftStorage' // ========== Storage Keys ========== /** 生成流程的完整Payload(包含mode、images等) */ @@ -90,18 +90,9 @@ export function padImagesForSelection(images, minCount = 4) { function persistFormData(formData) { if (formData != null) { try { - uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData)) - } catch (e) { - console.error('persistFormData failed:', e) - if (e.name === 'QuotaExceededError' || e.message?.includes('quota')) { - return { - success: false, - error: { - title: '存储空间不足', - content: '图片数据过大,请尝试压缩图片后重新提交' - } - } - } else { + const saved = writeDraft('castlove_form_data', JSON.stringify(formData)) + if (!saved) { + console.error('[castloveGenerationFlow] persistFormData: 保存失败(未登录或存储异常)') return { success: false, error: { @@ -110,6 +101,15 @@ function persistFormData(formData) { } } } + } catch (e) { + console.error('[castloveGenerationFlow] persistFormData 抛出异常:', e) + return { + success: false, + error: { + title: '存储失败', + content: '表单数据保存失败,请重试' + } + } } } return { success: true } @@ -326,23 +326,44 @@ export function startPrefilledSelectionFlow({ * @param {Object} formData - 包含lenticularBgImage、lenticularSubjectImage等字段 */ export function persistLenticularPreviewMeta(formData) { - uni.setStorageSync( - LENTICULAR_STUDIO_STORAGE_KEY, - JSON.stringify({ - bgPath: formData.lenticularBgImage || '', - subjectPath: formData.lenticularSubjectImage || '', - bgBase64: formData.lenticularBgBase64 || '', - subjectBase64: formData.lenticularSubjectBase64 || '', - nftInfo: formData.info || formData.nftInfo || '', - materialTypeIndex: formData.materialTypeIndex ?? 0, - aiDescription: formData.aiDescription || '', - }) - ) - uni.setStorageSync( - GENERATION_RESULT_META_KEY, - JSON.stringify({ displayMode: STUDIO_LENTICULAR, imageCount: 1 }) - ) - uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify([{ type: 'lenticular' }])) + try { + const saved = writeDraft( + 'lenticular_studio_payload', + JSON.stringify({ + bgPath: formData.lenticularBgImage || '', + subjectPath: formData.lenticularSubjectImage || '', + bgBase64: formData.lenticularBgBase64 || '', + subjectBase64: formData.lenticularSubjectBase64 || '', + nftInfo: formData.info || formData.nftInfo || '', + materialTypeIndex: formData.materialTypeIndex ?? 0, + aiDescription: formData.aiDescription || '', + }) + ) + if (!saved) { + console.error('[castloveGenerationFlow] persistLenticularPreviewMeta: lenticular_studio_payload 保存失败') + } + } catch (e) { + console.error('[castloveGenerationFlow] persistLenticularPreviewMeta 抛出异常:', e) + } + try { + const saved = writeDraft( + 'generation_result_meta', + JSON.stringify({ displayMode: STUDIO_LENTICULAR, imageCount: 1 }) + ) + if (!saved) { + console.error('[castloveGenerationFlow] persistLenticularPreviewMeta: generation_result_meta 保存失败') + } + } catch (e) { + console.error('[castloveGenerationFlow] persistLenticularPreviewMeta 抛出异常:', e) + } + try { + const saved = writeDraft('generated_images', JSON.stringify([{ type: 'lenticular' }])) + if (!saved) { + console.error('[castloveGenerationFlow] persistLenticularPreviewMeta: generated_images 保存失败') + } + } catch (e) { + console.error('[castloveGenerationFlow] persistLenticularPreviewMeta 抛出异常:', e) + } } /** @@ -353,11 +374,25 @@ export function persistLenticularPreviewMeta(formData) { * @param {string} [instanceNo] - 镭射卡实例编号 */ export function persistLaserPreviewImages(paths, instanceNo) { - uni.setStorageSync( - GENERATION_RESULT_META_KEY, - JSON.stringify({ displayMode: STUDIO_LASER, imageCount: paths.length, instanceNo: instanceNo || '' }) - ) - uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(paths)) + try { + const saved = writeDraft( + 'generation_result_meta', + JSON.stringify({ displayMode: STUDIO_LASER, imageCount: paths.length, instanceNo: instanceNo || '' }) + ) + if (!saved) { + console.error('[castloveGenerationFlow] persistLaserPreviewImages: generation_result_meta 保存失败') + } + } catch (e) { + console.error('[castloveGenerationFlow] persistLaserPreviewImages 抛出异常:', e) + } + try { + const saved = writeDraft('generated_images', JSON.stringify(paths)) + if (!saved) { + console.error('[castloveGenerationFlow] persistLaserPreviewImages: generated_images 保存失败') + } + } catch (e) { + console.error('[castloveGenerationFlow] persistLaserPreviewImages 抛出异常:', e) + } } /** @@ -379,24 +414,38 @@ export async function completeSelectionAndOpenDetail({ }) { const img = await materializeImageRef(selectedImage) const storedImage = img.path || img.base64 || selectedImage - uni.setStorageSync(CRAFT_SELECTED_IMAGE_KEY, storedImage) + try { + const saved = writeDraft('craft_selected_image', storedImage) + if (!saved) { + console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail: craft_selected_image 保存失败') + } + } catch (e) { + console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail 抛出异常:', e) + } uni.setStorageSync(CRAFT_SELECTED_INDEX_KEY, String(selectedIndex ?? 0)) // 根据工作室类型保存特定的预览元数据 if (formData?.studio_kind === STUDIO_LENTICULAR) { const subjectRef = storedImage - uni.setStorageSync( - LENTICULAR_STUDIO_STORAGE_KEY, - JSON.stringify({ - bgPath: formData.lenticularBgImage || '', - subjectPath: subjectRef, - bgBase64: formData.lenticularBgBase64 || '', - subjectBase64: img.base64 || formData.lenticularSubjectBase64 || '', - nftInfo: formData.info || formData.nftInfo || '', - materialTypeIndex: formData.materialTypeIndex ?? 0, - aiDescription: formData.aiDescription || '', - }) - ) + try { + const saved = writeDraft( + 'lenticular_studio_payload', + JSON.stringify({ + bgPath: formData.lenticularBgImage || '', + subjectPath: subjectRef, + bgBase64: formData.lenticularBgBase64 || '', + subjectBase64: img.base64 || formData.lenticularSubjectBase64 || '', + nftInfo: formData.info || formData.nftInfo || '', + materialTypeIndex: formData.materialTypeIndex ?? 0, + aiDescription: formData.aiDescription || '', + }) + ) + if (!saved) { + console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail: lenticular_studio_payload 保存失败') + } + } catch (e) { + console.error('[castloveGenerationFlow] completeSelectionAndOpenDetail 抛出异常:', e) + } } // 镭射(studio_kind === laser)选卡与铸造在 laser-result + useLaserMint,不在此写入工坊 entry diff --git a/frontend/utils/craftMintSubmit.js b/frontend/utils/craftMintSubmit.js index 58e8979..2af15d6 100644 --- a/frontend/utils/craftMintSubmit.js +++ b/frontend/utils/craftMintSubmit.js @@ -1,6 +1,7 @@ import { getOssSignatureApi, createMintOrderApi, uploadMaterialApi, bindAssetMaterialsApi } from '@/utils/api.js' import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js' import { buildCastloveFormSnapshot, CRAFT_TAG_LASER, CRAFT_TAG_LENTICULAR } from '@/utils/castloveMintForm.js' +import { writeDraft } from '@/utils/draftStorage' function uploadFileToOss(tempFilePath, ossData) { return new Promise((resolve, reject) => { @@ -722,7 +723,14 @@ export async function submitCraftMintFromPath({ imagePath, bgImagePath, laserBac nftData.laser_cutout_path = cutoutPath } } - uni.setStorageSync('temp_nft_data', JSON.stringify(nftData)) + try { + const saved = writeDraft('temp_nft_data', JSON.stringify(nftData)) + if (!saved) { + console.error('[craftMintSubmit] 保存 NFT 数据失败(未登录或存储异常)') + } + } catch (e) { + console.error('[craftMintSubmit] 保存 NFT 数据失败:', e) + } uni.removeStorageSync('castlove_form_data') console.log('[craftMintSubmit] 完成') diff --git a/frontend/utils/draftStorage.js b/frontend/utils/draftStorage.js new file mode 100644 index 0000000..a55d371 --- /dev/null +++ b/frontend/utils/draftStorage.js @@ -0,0 +1,127 @@ +// frontend/utils/draftStorage.js +// 草稿 storage 双读单写封装 +// 目的:账号隔离 + 迁移兼容(老无 uid 后缀 key 过渡到 *_${uid} 形式) + +// 注意:BASE_KEYS 是**实际存储 key 值**(不是 JS 变量名) +// 'CASTLOVE_FORM_KEY' 是 export const 名,其值是 'castlove_form_data',因此不要写 'CASTLOVE_FORM_KEY' +const BASE_KEYS = [ + 'castlove_form_data', + 'temp_nft_data', + 'generated_images', + 'generation_result_meta', + 'lenticular_studio_payload', + 'craft_selected_image', +] + +/** + * 取当前用户 uid(用于拼 key 后缀) + * @returns {string|null} + */ +export function getCurrentUid() { + try { + const userStr = uni.getStorageSync('user') + if (!userStr) return null + const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr + return user?.uid ? String(user.uid) : null + } catch (e) { + return null + } +} + +/** + * 构造带 uid 后缀的 key(无 uid 时 fallback 到原 key,用于未登录态的兼容性读) + */ +function buildKey(baseKey, uid) { + return uid ? `${baseKey}_${uid}` : baseKey +} + +/** + * 读取草稿(双读:先新后旧,命中后迁移) + * **返回原始 storage 值**(字符串或对象,取决于写入方式),由调用方决定是否 JSON.parse。 + * 这样保持与原 `uni.getStorageSync(...)` 后接 `JSON.parse(...)` 的调用模式完全兼容。 + * @param {string} baseKey + * @returns {any|null} 原始值(可能是 JSON 字符串或已 stringify 的对象);null = 未命中 + */ +export function readDraft(baseKey) { + const uid = getCurrentUid() + // 1. 优先读新 key + const newKey = buildKey(baseKey, uid) + try { + const val = uni.getStorageSync(newKey) + if (val !== '' && val !== null && val !== undefined) return val + } catch (e) { /* 继续 fallback */ } + + // 2. fallback 读老 key(仅登录态有意义) + if (uid) { + try { + const oldVal = uni.getStorageSync(baseKey) + if (oldVal !== '' && oldVal !== null && oldVal !== undefined) { + // 3. 迁移:写到新 key,删老 key + try { + uni.setStorageSync(newKey, oldVal) + uni.removeStorageSync(baseKey) + } catch (e) { /* 迁移失败也返回数据 */ } + return oldVal + } + } catch (e) { /* ignore */ } + } + return null +} + +/** + * 写入草稿(仅写新 key;未登录态拒绝写) + * @param {string} baseKey + * @param {string|object} value 通常是 JSON.stringify 后的字符串 + * @returns {boolean} 是否写入成功 + */ +export function writeDraft(baseKey, value) { + const uid = getCurrentUid() + if (!uid) return false + const newKey = buildKey(baseKey, uid) + try { + uni.setStorageSync(newKey, typeof value === 'string' ? value : JSON.stringify(value)) + return true + } catch (e) { + console.warn('[draftStorage] writeDraft failed:', baseKey, e.message) + return false + } +} + +/** + * 删除草稿(仅删当前 uid 的新 key + 老 key) + */ +export function removeDraft(baseKey) { + const uid = getCurrentUid() + const newKey = buildKey(baseKey, uid) + try { uni.removeStorageSync(newKey) } catch (e) { /* skip */ } + try { uni.removeStorageSync(baseKey) } catch (e) { /* skip */ } +} + +/** + * 获取所有 base key(用于 handler 遍历) + */ +export function getAllBaseKeys() { + return BASE_KEYS.slice() +} + +/** + * 列出当前 uid 的所有草稿 key(含老 key 兜底) + */ +export function listCurrentDraftKeys() { + const uid = getCurrentUid() + try { + const info = uni.getStorageInfoSync() + const allKeys = info.keys || [] + if (uid) { + return allKeys.filter((k) => { + return BASE_KEYS.some((base) => k === `${base}_${uid}` || k === base) + }) + } else { + return allKeys.filter((k) => BASE_KEYS.includes(k)) + } + } catch (e) { + return [] + } +} + +// safeParse 移除(readDraft 现在返回原值,调用方自行 JSON.parse) diff --git a/frontend/utils/handlers/draftHandler.js b/frontend/utils/handlers/draftHandler.js new file mode 100644 index 0000000..fb43b6e --- /dev/null +++ b/frontend/utils/handlers/draftHandler.js @@ -0,0 +1,162 @@ +// frontend/utils/handlers/draftHandler.js +// 创作草稿 handler(分组型,按 uid 分组;草稿 key 已通过 M0 改造为 *_${uid} 形式) + +import { getCurrentUid, getAllBaseKeys } from '@/utils/draftStorage' + +function bytesOf(keys) { + let total = 0 + for (const k of keys) { + try { const v = uni.getStorageSync(k); if (v != null) total += JSON.stringify(v).length } catch (e) {} + } + return total +} + +function listAllDraftLikeKeys() { + try { + const info = uni.getStorageInfoSync() + const all = info.keys || [] + const baseKeys = getAllBaseKeys() + return all.filter((k) => baseKeys.some((base) => k === base || k.startsWith(`${base}_`))) + } catch (e) { return [] } +} + +function extractUidFromDraftKey(key, baseKey) { + const prefix = `${baseKey}_` + if (!key.startsWith(prefix)) return null + return key.slice(prefix.length) +} + +/** + * Remove the listed storage keys, counting only successful removals. + * Reads each value first to capture accurate freedBytes before deletion. + * @param {string[]} targets + * @returns {{freedBytes: number, keyCount: number}} + */ +function removeAndCount(targets) { + let freed = 0 + let count = 0 + for (const k of targets) { + try { + let bytes = 0 + try { const v = uni.getStorageSync(k); if (v != null) bytes = JSON.stringify(v).length } catch (e) {} + uni.removeStorageSync(k) + freed += bytes + count++ + } catch (e) { + console.warn('[draftHandler] remove failed:', k, e.message) + } + } + return { freedBytes: freed, keyCount: count } +} + +export default { + id: 'draft', + label: '创作中的草稿', + description: '未提交的创作表单/生成结果', + warning: true, + warningText: '将清空你未提交的创作草稿,是否继续?', + strongWarningText: '将清空该账号的草稿,对方下次登录不会看到。是否继续?', + + async computeSize() { + const keys = listAllDraftLikeKeys() + return { sizeBytes: bytesOf(keys), keyCount: keys.length } + }, + + async computeBreakdown() { + const currentUid = getCurrentUid() + const allKeys = listAllDraftLikeKeys() + const groups = [] + const baseKeys = getAllBaseKeys() + const isLoggedIn = !!currentUid + + // 按 uid 聚合 + const byUid = new Map() + for (const k of allKeys) { + const matchedBase = baseKeys.find((b) => k === b || k.startsWith(`${b}_`)) + if (!matchedBase) continue + const uid = extractUidFromDraftKey(k, matchedBase) + const groupUid = uid || '__legacy__' + if (!byUid.has(groupUid)) byUid.set(groupUid, []) + byUid.get(groupUid).push(k) + } + + for (const [uid, keys] of byUid) { + const isCurrent = uid === currentUid + const isLegacy = uid === '__legacy__' + let canClean, disabledReason + if (!isLoggedIn) { + canClean = false + disabledReason = 'logged-out' + } else if (isLegacy) { + // 老无 uid 后缀 key 单独分组(不与他人草稿混合) + canClean = keys.length > 0 + disabledReason = keys.length === 0 ? 'empty' : null + } else { + canClean = keys.length > 0 + disabledReason = keys.length === 0 ? 'empty' : null + } + groups.push({ + uid, + displayUid: isLegacy ? '老版本数据' : (isCurrent ? `我的 (${uid})` : `其他用户 (${uid})`), + isCurrent, + sizeBytes: bytesOf(keys), + keyCount: keys.length, + canClean, + disabledReason, + }) + } + + return groups.sort((a, b) => b.sizeBytes - a.sizeBytes) + }, + + async cleanGroup({ uid }) { + const currentUid = getCurrentUid() + const allKeys = listAllDraftLikeKeys() + const baseKeys = getAllBaseKeys() + + const reserved = new Set() + if (currentUid) { + // 保留当前 uid 的 keys + for (const k of allKeys) { + const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`)) + if (!base) continue + if (extractUidFromDraftKey(k, base) === currentUid) reserved.add(k) + } + } + + if (uid === 'self') { + const targets = allKeys.filter((k) => { + const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`)) + if (!base) return false + return extractUidFromDraftKey(k, base) === currentUid + }) + return removeAndCount(targets) + } else if (uid === '__legacy__') { + // 单独清老无 uid 后缀 key(不删其他用户的!) + const targets = allKeys.filter((k) => { + const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`)) + if (!base) return false + return k === base + }) + return removeAndCount(targets) + } else if (uid === 'others') { + // 其他用户聚合(不含 legacy) + const targets = allKeys.filter((k) => { + const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`)) + if (!base) return false + const u = extractUidFromDraftKey(k, base) + return u && u !== currentUid + }) + return removeAndCount(targets) + } else { + // 具体其他 uid + const targets = allKeys.filter((k) => { + const base = baseKeys.find((b) => k === b || k.startsWith(`${b}_`)) + if (!base) return false + if (reserved.has(k)) return false // defensive + return extractUidFromDraftKey(k, base) === uid + }) + return removeAndCount(targets) + } + }, +} diff --git a/frontend/utils/handlers/guideHandler.js b/frontend/utils/handlers/guideHandler.js new file mode 100644 index 0000000..4fefa52 --- /dev/null +++ b/frontend/utils/handlers/guideHandler.js @@ -0,0 +1,138 @@ +// frontend/utils/handlers/guideHandler.js +// 引导记录 handler(分组型,含保留规则) +// spec §3.4.2: 保留当前 uid 的 + 全局 key;其他用户组仅在登录态可清 + +import { getCurrentUid } from '@/utils/draftStorage' // 复用 +import { listGuideKeys, extractUidFromGuideKey } from './guideKeys' + +const GLOBAL_PROTECTED_KEYS = new Set(['guide_debug_mode', 'guide_first_show']) + +function isGlobalKey(key) { + // guide_shown_xxx 是无 uid 段(但有 configKey) + if (GLOBAL_PROTECTED_KEYS.has(key)) return true + if (key.startsWith('guide_shown_')) return true + return false +} + +function bytesOf(keys) { + let total = 0 + for (const k of keys) { + try { const v = uni.getStorageSync(k); if (v != null) total += JSON.stringify(v).length } catch (e) {} + } + return total +} + +export default { + id: 'guide', + label: '引导记录', + description: '新手引导完成标记', + warning: false, + + async computeSize() { + const keys = listGuideKeys() + return { sizeBytes: bytesOf(keys), keyCount: keys.length } + }, + + async computeBreakdown() { + const currentUid = getCurrentUid() + const allKeys = listGuideKeys() + const groups = [] + const isLoggedIn = !!currentUid + + // 1. 我的(currentUid)— 保留不可清 + if (currentUid) { + const myKeys = allKeys.filter((k) => extractUidFromGuideKey(k) === currentUid) + groups.push({ + uid: currentUid, displayUid: currentUid, isCurrent: true, + sizeBytes: bytesOf(myKeys), keyCount: myKeys.length, + canClean: false, disabledReason: 'guide-current', + }) + } + + // 2. 其他用户(按 uid 聚合)— 仅登录态可清;未登录态保留展示但按钮置灰 + const otherUidKeys = new Map() + for (const k of allKeys) { + const uid = extractUidFromGuideKey(k) + if (!uid || uid === currentUid) continue + if (!otherUidKeys.has(uid)) otherUidKeys.set(uid, []) + otherUidKeys.get(uid).push(k) + } + for (const [uid, keys] of otherUidKeys) { + groups.push({ + uid, displayUid: uid, isCurrent: false, + sizeBytes: bytesOf(keys), keyCount: keys.length, + canClean: isLoggedIn && keys.length > 0, + disabledReason: isLoggedIn ? (keys.length === 0 ? 'empty' : null) : 'logged-out', + }) + } + + // 3. 全局 guide_*(无 uid 段)— 永远不可清 + const globalKeys = allKeys.filter(isGlobalKey) + if (globalKeys.length > 0) { + groups.push({ + uid: '__global__', displayUid: '全局标记', isCurrent: false, + sizeBytes: bytesOf(globalKeys), keyCount: globalKeys.length, global: true, + canClean: false, disabledReason: 'guide-global', + }) + } + + // 按 sizeBytes 降序(spec §4.3.2 要求) + return groups.sort((a, b) => b.sizeBytes - a.sizeBytes) + }, + + async cleanGroup({ uid }) { + const currentUid = getCurrentUid() + const allKeys = listGuideKeys() + + // 保留:当前 uid 的 + 全局 key + const reserved = new Set() + if (currentUid) { + for (const k of allKeys) if (extractUidFromGuideKey(k) === currentUid) reserved.add(k) + } + for (const k of allKeys) if (isGlobalKey(k)) reserved.add(k) + + let targetKeys = [] + if (uid === 'self') { + // 不允许清自己的(保留规则) + return { freedBytes: 0, keyCount: 0 } + } else if (uid === null) { + // 其他用户聚合(不含全局) + targetKeys = allKeys.filter((k) => { + if (reserved.has(k)) return false + const u = extractUidFromGuideKey(k) + return u && u !== currentUid + }) + } else if (uid === '__global__') { + // 全局 key 也不允许清 + return { freedBytes: 0, keyCount: 0 } + } else { + // 具体其他 uid + targetKeys = allKeys.filter((k) => { + if (reserved.has(k)) return false // defensive + return extractUidFromGuideKey(k) === uid + }) + } + + // 逐 key:先量后删(per-key 精确值,避免 approximation) + let freed = 0 + let count = 0 + for (const k of targetKeys) { + let valueBytes = 0 + try { + const v = uni.getStorageSync(k) + if (v != null) valueBytes = JSON.stringify(v).length + } catch (e) { + console.warn('[guideHandler] read failed:', k, e.message) + continue + } + try { + uni.removeStorageSync(k) + freed += valueBytes + count++ + } catch (e) { + console.warn('[guideHandler] remove failed:', k, e.message) + } + } + return { freedBytes: freed, keyCount: count } + }, +} diff --git a/frontend/utils/handlers/guideKeys.js b/frontend/utils/handlers/guideKeys.js new file mode 100644 index 0000000..58aa703 --- /dev/null +++ b/frontend/utils/handlers/guideKeys.js @@ -0,0 +1,30 @@ +// frontend/utils/handlers/guideKeys.js +// guide 分类工具函数(供 guideHandler 和 othersHandler 共用) + +const GUIDE_PREFIX = 'guide_' + +/** + * 列出所有 guide_* key + */ +export function listGuideKeys() { + try { + const info = uni.getStorageInfoSync() + return (info.keys || []).filter((k) => k.startsWith(GUIDE_PREFIX)) + } catch (e) { return [] } +} + +/** + * 解析 key 中包含的 uid(如果有) + * 支持两种 owner 段: + * - 数字 uid(如 'guide_done_10001_intro') + * - 'default'(如 'guide_done_default_intro',guideConfig.js 在未登录时使用) + */ +export function extractUidFromGuideKey(key) { + // 先试数字 + const m = key.match(/_(\d+)(?:_|$)/) + if (m) return m[1] + // 再试 default + const d = key.match(/_(default)(?:_|$)/) + if (d) return 'default' + return null +} diff --git a/frontend/utils/handlers/othersHandler.js b/frontend/utils/handlers/othersHandler.js new file mode 100644 index 0000000..b385f3b --- /dev/null +++ b/frontend/utils/handlers/othersHandler.js @@ -0,0 +1,79 @@ +// frontend/utils/handlers/othersHandler.js +// 兜底分类:未匹配其他规则的 key(且不在黑名单) +// 注意:草稿 keys(无论 currentUid / otherUid / legacy)**全部**归 draftHandler, +// 否则会双重计入 totalBytes 且强警告无法生效 +// +// 安全白名单(reviewer P0):以下 key 属于其他模块的工作流关键状态, +// 严禁被 othersHandler 清理: +import { isProtectedKey } from '@/utils/cacheManager' +import { getAllBaseKeys } from '@/utils/draftStorage' + +const PRELOAD_PREFIX = 'preload:' +const PROGRESS_PREFIX = 'progress_' +const GUIDE_PREFIX = 'guide_' +const DRAFT_BASE_KEYS = getAllBaseKeys() + +// 工作流关键 key 白名单(reviewer P0 修正:原"全收"策略会破坏这些) +const WORKFLOW_CRITICAL_KEYS = new Set([ + 'generation_flow_payload', + 'generation_request_data', + 'craft_selected_index', + '__package_info__', + 'UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH', +]) + +function isClaimed(key) { + if (isProtectedKey(key)) return false + if (WORKFLOW_CRITICAL_KEYS.has(key)) return false + if (key.startsWith('topfans_pending_actions_')) return false + if (key.startsWith(PRELOAD_PREFIX)) return false + if (key.startsWith(PROGRESS_PREFIX)) return false + if (key.startsWith(GUIDE_PREFIX)) return false + if (DRAFT_BASE_KEYS.some((base) => key === base || key.startsWith(`${base}_`))) return false + return true +} + +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) + const info = uni.getStorageInfoSync() + const keys = (info.keys || []).filter(isClaimed) + + let freed = 0 + let count = 0 + for (const k of keys) { + let valueBytes = 0 + try { + const v = uni.getStorageSync(k) + 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 + } + try { + uni.removeStorageSync(k) + freed += valueBytes + count++ + } catch (e) { + console.warn('[othersHandler] remove failed:', k, e.message) + // don't count as freed + } + } + return { freedBytes: freed, keyCount: count } + }, +} \ No newline at end of file diff --git a/frontend/utils/handlers/preloadHandler.js b/frontend/utils/handlers/preloadHandler.js new file mode 100644 index 0000000..ff0ff87 --- /dev/null +++ b/frontend/utils/handlers/preloadHandler.js @@ -0,0 +1,51 @@ +// frontend/utils/handlers/preloadHandler.js +// preload 缓存 handler(简单型,但跨用户清理 + invalidateAll) +// 跨用户:删除所有 preload:xxx:* 不依赖 currentUid +// 内存清理:由 cacheManager.cleanCategory 触发 invalidateAll + +const PREFIX = 'preload:' + +function listAllPreloadKeys() { + try { + const info = uni.getStorageInfoSync() + return (info.keys || []).filter((k) => k.startsWith(PREFIX)) + } catch (e) { return [] } +} + +export default { + id: 'preload', + label: '预加载数据缓存', + description: '列表页提前拉取的数据', + warning: false, + async computeSize() { + const keys = listAllPreloadKeys() + 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 } + }, + async clean() { + const keys = listAllPreloadKeys() + let freed = 0 + let count = 0 + for (const k of keys) { + let valueBytes = 0 + try { + const v = uni.getStorageSync(k) + if (v != null) valueBytes = JSON.stringify(v).length + } catch (e) { + console.warn('[preloadHandler] read failed:', k, e.message) + continue + } + try { + uni.removeStorageSync(k) + freed += valueBytes + count++ + } catch (e) { + console.warn('[preloadHandler] remove failed:', k, e.message) + } + } + return { freedBytes: freed, keyCount: count } + }, +} diff --git a/frontend/utils/handlers/progressHandler.js b/frontend/utils/handlers/progressHandler.js new file mode 100644 index 0000000..851f007 --- /dev/null +++ b/frontend/utils/handlers/progressHandler.js @@ -0,0 +1,46 @@ +// frontend/utils/handlers/progressHandler.js +// 活动进度缓存 handler(简单型) +import { isProtectedKey } from '@/utils/cacheManager' + +const PREFIX = 'progress_' + +function listProgressKeys() { + try { + const info = uni.getStorageInfoSync() + return (info.keys || []).filter((k) => k.startsWith(PREFIX) && !isProtectedKey(k)) + } catch (e) { return [] } +} + +function computeSizeOf(keys) { + let total = 0 + for (const k of keys) { + try { + const v = uni.getStorageSync(k) + if (v != null) total += JSON.stringify(v).length + } catch (e) { /* skip */ } + } + return total +} + +export default { + id: 'progress', + label: '活动进度缓存', + description: '支持活动页断网浏览', + warning: false, + async computeSize() { + const keys = listProgressKeys() + return { sizeBytes: computeSizeOf(keys), keyCount: keys.length } + }, + async clean() { + const keys = listProgressKeys() + let freed = 0 + for (const k of keys) { + try { + const v = uni.getStorageSync(k) + if (v != null) freed += JSON.stringify(v).length + uni.removeStorageSync(k) + } catch (e) { /* skip */ } + } + return { freedBytes: freed, keyCount: keys.length } + }, +} \ No newline at end of file diff --git a/frontend/utils/handlers/sandboxTmpHandler.js b/frontend/utils/handlers/sandboxTmpHandler.js new file mode 100644 index 0000000..f7f4099 --- /dev/null +++ b/frontend/utils/handlers/sandboxTmpHandler.js @@ -0,0 +1,27 @@ +// frontend/utils/handlers/sandboxTmpHandler.js +// 沙盒临时文件 handler(简单型) +// reviewer P0 修正:不再依赖私有 ioPath 内部函数,全部走 ioPath 导出 API +import { scanSandboxTmpFiles, countSandboxTmpFiles, clearAllSandboxTmpFiles } from '@/utils/ioPath' + +export default { + id: 'sandbox-tmp', + label: '临时文件', + description: '上传/分享过程产生的临时文件', + warning: false, + async computeSize() { + const [bytes, count] = await Promise.all([ + scanSandboxTmpFiles(), + countSandboxTmpFiles(), // 真实文件数(reviewer P0 修正) + ]) + return { sizeBytes: bytes, keyCount: count } + }, + 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 } + }, +} \ No newline at end of file diff --git a/frontend/utils/ioPath.js b/frontend/utils/ioPath.js index 2a57e7c..5cb054f 100644 --- a/frontend/utils/ioPath.js +++ b/frontend/utils/ioPath.js @@ -329,4 +329,195 @@ export async function clearAllSandboxTmpFiles() { // #ifndef APP-PLUS return Promise.resolve({ scanned: 0, deleted: 0, dirs: [] }) // #endif -} \ No newline at end of file +} + +/** + * 通过 plus.android 读取 APK 安装包大小(Android 专用,绕开 sandbox 限制) + * 在 iOS / H5 / 小程序上返回 0(调用方应改用 sandbox 大小) + * @returns {Promise} APK 字节数 + */ +async function getAndroidApkSize() { + // #ifdef APP-PLUS + try { + // 仅 Android + if (typeof plus === 'undefined' || !plus.android) return 0 + const main = plus.android.runtimeMainActivity() + if (!main) return 0 + const Context = plus.android.importClass('android.content.Context') + const FLAG_PKG_DATA = Context.PM_FLAGS || 0 // 不一定存在 + const pm = main.getPackageManager() + const pkg = main.getPackageName() + // getPackageSizeInfo 是异步回调,这里改用 sourceDir 同步读 + const appInfo = pm.getApplicationInfo(pkg, 0) + const sourceDir = appInfo.sourceDir + if (!sourceDir) return 0 + const File = plus.android.importClass('java.io.File') + const f = new File(sourceDir) + const size = f.length() + if (typeof size === 'number' && size > 0) return size + return 0 + } catch (e) { + console.warn('[ioPath] getAndroidApkSize failed:', e?.message) + return 0 + } + // #endif + // #ifndef APP-PLUS + return 0 + // #endif +} + +/** + * 计算"app 已用空间" = Android APK 安装包大小 + sandbox 运行时数据大小 + * 在 iOS / H5 上只算 sandbox(iOS bundle 不易从 js 取大小,按 sandbox 估算) + * 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes + */ +export async function getSandboxTotalSize() { + // #ifdef APP-PLUS + try { + // Android: APK 大小 + sandbox doc 目录运行时数据 + const apkSize = await getAndroidApkSize() + let sandboxBytes = 0 + try { + const root = await getSandboxRootDir() + sandboxBytes = await _sumDirSize(root) + } catch (e) { + console.warn('[ioPath] _sumDirSize failed:', e?.message) + } + return apkSize + sandboxBytes + } catch (e) { + console.warn('[ioPath] getSandboxTotalSize failed:', e?.message) + return 0 + } + // #endif + // #ifndef APP-PLUS + return 0 + // #endif +} + +/** + * 扫描 sandbox doc 下"所有业务子目录的 tmp/ 文件总字节数"(不包含白名单 preload/share/image) + * 用于 sandboxTmpHandler.computeSize + * + * 与 getSandboxTotalSize 的区别: + * - getSandboxTotalSize: 沙盒 doc 下所有文件(含 preload/share/image 白名单) + * - scanSandboxTmpFiles: 只统计 tmp/ 子目录(用于清理项;与 clearAllSandboxTmpFiles 一致) + */ +export async function scanSandboxTmpFiles() { + // #ifdef APP-PLUS + try { + const root = await getSandboxRootDir() + const topEntries = await _listDir(root) + let total = 0 + for (const top of topEntries) { + if (!top.isDirectory) continue + if (PROTECTED_SUBDIRS.has(top.name)) continue + const tmpEntry = await new Promise((resolve) => { + top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null)) + }) + if (!tmpEntry) continue + total += await _sumDirSize(tmpEntry) + } + return total + } catch (e) { + console.warn('[ioPath] scanSandboxTmpFiles failed:', e?.message) + return 0 + } + // #endif + // #ifndef APP-PLUS + return 0 + // #endif +} + +/** + * 计数 sandbox doc 下 tmp/ 子目录的文件总数(reviewer P0修正:导出供 sandboxTmpHandler 使用) + * 与 scanSandboxTmpFiles 范围一致(仅 tmp/,不含白名单 preload/share/image) + */ +export async function countSandboxTmpFiles() { + // #ifdef APP-PLUS + try { + const root = await getSandboxRootDir() + const topEntries = await _listDir(root) + let count = 0 + const walk = async (dir) => { + const entries = await _listDir(dir) + for (const e of entries) { + if (e.isFile) count++ + else if (e.isDirectory) await walk(e) + } + } + for (const top of topEntries) { + if (!top.isDirectory) continue + if (PROTECTED_SUBDIRS.has(top.name)) continue + const tmpEntry = await new Promise((resolve) => { + top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null)) + }) + if (!tmpEntry) continue + await walk(tmpEntry) + } + return count + } catch (e) { + console.warn('[ioPath] countSandboxTmpFiles failed:', e?.message) + return 0 + } + // #endif + // #ifndef APP-PLUS + return 0 + // #endif +} + +/** + * 递归求和目录下所有文件大小(bytes) + */ +async function _sumDirSize(dirEntry) { + // #ifdef APP-PLUS + let total = 0 + let entries + try { + entries = await _listDir(dirEntry) + } catch (e) { + // 单个目录读失败 → 跳过整个目录(不影响兄弟目录) + console.warn('[ioPath] _sumDirSize read failed for a dir, skipping:', e?.message) + return total + } + for (const entry of entries) { + if (entry.isDirectory) { + total += await _sumDirSize(entry) + } else if (entry.isFile) { + try { + const file = await new Promise((res, rej) => entry.file(res, rej)) + total += (file.size || 0) + } catch (e) { /* skip individual file */ } + } + } + return total + // #endif + // #ifndef APP-PLUS + return 0 + // #endif +} + +/** + * 列出目录所有 entry(DirectoryEntry / FileEntry) + */ +async function _listDir(dirEntry) { + // #ifdef APP-PLUS + return new Promise((resolve, reject) => { + const reader = dirEntry.createReader() + const collected = [] + const readAll = () => { + reader.readEntries( + (es) => { + if (es.length === 0) return resolve(collected) + collected.push(...es) + readAll() + }, + (err) => reject(err) + ) + } + readAll() + }) + // #endif + // #ifndef APP-PLUS + return [] + // #endif +} diff --git a/frontend/utils/preloadApi/core.js b/frontend/utils/preloadApi/core.js index 15b7838..b5844c1 100644 --- a/frontend/utils/preloadApi/core.js +++ b/frontend/utils/preloadApi/core.js @@ -27,6 +27,9 @@ const inFlightMap = new Map() // Map let hitCount = 0 let missCount = 0 +// 清理代际计数器:invalidateAll() 增加;_doFetch 启动时捕获,提交结果前比对,避免清理期间写入过期数据 +let _cleanupGeneration = 0 + // 运行时配置(由外部 setConfig 写入) let _config = { defaults: { @@ -247,10 +250,26 @@ export function invalidatePrefix(prefix) { } /** - * 清空全部内存缓存(不动文件缓存) + * 清空全部内存缓存 + 取消所有 in-flight 请求(不动文件缓存) + * 写入路径会在 _doFetch 内通过 _cleanupGeneration 比对自动失效 */ export function invalidateAll() { + // 1. 标记清理事件,in-flight fetch 在写入前会比对 generation + _cleanupGeneration++ + // 2. 清空内存缓存 memoryMap.clear() + // 3. 取消所有 in-flight 请求 + 清空 inFlightMap + for (const [key, entry] of inFlightMap) { + try { entry.abort() } catch (e) { /* skip */ } + } + inFlightMap.clear() +} + +/** + * 获取当前清理代际(调试 / 测试用) + */ +export function getCleanupGeneration() { + return _cleanupGeneration } /** @@ -389,6 +408,9 @@ async function _doFetch(logicalKey, params, cacheKey, userId, isRun, skipFileCac inFlightMap.delete(cacheKey) } + // 捕获启动时的清理代际,提交结果前比对,避免在 invalidateAll() 之后写入过期数据 + const genAtStart = _cleanupGeneration + // 5. 并发控制 + 超时 await _semaphore.acquire() @@ -416,6 +438,13 @@ async function _doFetch(logicalKey, params, cacheKey, userId, isRun, skipFileCac const elapsed = Date.now() - startTime console.log('[preload] fetch done:', logicalKey, elapsed + 'ms') + // Generation check:若 invalidateAll() 在 fetch 期间已触发,丢弃本次结果不写入 + if (genAtStart !== _cleanupGeneration) { + console.warn('[preload] fetch stale, cleanup generation changed:', logicalKey) + resolveInFlight(null) + return null + } + // 6. 写内存缓存 const entry = { data: result, ts: Date.now(), ttl, persistence } touchLRU(memoryMap, cacheKey, entry)