Files
spark-store/docs/app-list-cache-analysis.md
T
xiyidaiwa 7cafcc51f6 fix(download): 下载失败时补全日志,避免卡在"正在获取 Metalink"突兀退出
- 主进程 Metalink 下载请求失败显式 sendLog 并回传渲染端(原仅静默 logger.error)
- aria2c 拉起失败补 sendLog 日志
- Metalink 写入完成补 sendLog 提示
- 渲染端 install-complete 失败分支将错误原因写入 logs,并补成功/失败终止日志
- 配套: 缓存修复(C1 主进程 no-cache + C2 axios ?_t 版本戳) 已在上一提交实现
- 文档 docs/app-list-cache-analysis.md 新增第8节下载日志断层修复方案
2026-08-11 18:50:31 +08:00

21 KiB
Raw Blame History

应用列表缓存导致"新应用上架后搜不到"的根因分析与设计方案

App List Cache Analysis & Design (spark-store) 整理日期:2026-08-11 背景分支:Erotica 文档涵盖:① 列表缓存根因与修复(C1/C2);② 下载日志断层修复(附加)


0. 现象(用户反馈)

  • 服务端上新应用后,在商店内搜不到该应用。
  • 直觉认为是 ~/.cache 下的本地缓存导致,但实测并非如此。
  • 进一步反馈:重启应用也无效,只有"删除缓存"才行——这指向缓存层在重启后仍返回陈旧数据。

1. 缓存层逐层排查

缓存层 是否存在 证据 是否导致本问题
① 内存(渲染进程 apps 数组) 搜索基于内存 baseAppsApp.vue:665);loadApps 仅在 onMounted 调用一次(App.vue:3447 是(叠加因素):运行期不刷新,新应用不在内存
② 本地磁盘"应用列表"文件缓存 源码无 writeFile(applist)~/.config/spark-store/ 下无应用列表缓存文件 否(用户最初怀疑点,已排除)
③ Chromium 网络磁盘缓存 是(关键) 渲染进程 axios 走 XHR,受 Chromium 网络栈影响;~/.config/spark-store/Cache 目录存在 :重启后请求命中该磁盘缓存,返回旧 applist.json
④ CDN 边缘缓存 是(关键) 生产域名 erotica.spark-app.store 挂 CDN;静态 applist.json 被边缘缓存;代码未发 no-cache :即使穿透③,CDN 仍可能返回边缘陈旧副本

请求侧缓存控制现状(代码事实)

  • fetchWithRetryApp.vue:432):axiosInstance.get(url, { signal })无任何 Cache-Control
  • axiosInstanceApp.vue:427):仅 baseURL + timeout,无 headers。
  • 主进程 onBeforeSendHeaderselectron/main/index.ts:653):只注入 User-Agent未注入 Cache-Control: no-cache
  • loadAppsApp.vue:3216):apps.value.push(...) —— append 模式、无去重、无重置,重跑会累积重复。
  • loadTabAppsApp.vue:3098):if (tabApps.value[entryId]) return; —— 已加载分类强缓存,永不重拉
  • loadCategoriesApp.vue:3430):categories.json 仅启动时加载一次;若为"新分类"则永不加载其 applist.json

2. 根因结论(实测修正)

2026-08-11 实测修正:曾假设"CDN 边缘主动缓存"curl 实测远程 applist.json 响应头为 server: nginx + etag + last-modified无任何 Cache-Control/Expires/Age。因此真因是 nginx 未发缓存控制头 → Chromium 启发式缓存(heuristic caching,而非 CDN 主动边缘缓存。

主因(解释"重启无效、删除才行")—— Chromium 启发式缓存:

对"无 Cache-Control 头"的响应,Chromium 按 Last-Modified 计算启发式 TTL ≈ (now - LM)/10。实测当前文件 LM 距今约 7.6h → TTL ≈ 46 分钟。在此窗口内:

  • 重启应用 → 重新发请求 → Chromium 直接复用 ~/.config/spark-store/Cache 旧副本(启发式视为"新鲜")→ 返回旧 JSON → 新应用搜不到。
  • 只有删除 ~/.config/spark-store/Cache 强制失效,才可能拿到新内容。 这与"重启无效、删缓存才行"的现象精确吻合

叠加因素(解释"运行期搜不到"):

loadApps 仅在启动时加载一次、且 loadTabApps 对已加载分类强缓存,运行期内存数据不会自动更新。即便请求能穿透缓存,也需重启/手动触发才进入内存。

次要因素(条件触发):

若新应用落入 categories.json 尚未包含的新分类loadApps 遍历 categories.value 不会请求该分类,连重启都搜不到,需等 categories.json 同步。

说明:用户最初怀疑的"~/.cache 应用列表文件缓存"不存在;真因是 nginx 无 CC 头导致的 Chromium 启发式缓存(③层的精确机理),其陈旧表现与"文件缓存"一致,故被误判。

2.1 实测证据(curl / Node2026-08-11

  • curl -sI 远程 applist.jsonHTTP/2 200server: nginxetag: "6a7a764e-2f9d8"last-modified: Tue, 11 Aug 2026 01:09:34 GMTcache-control/expires/age
  • Node 计算:LM 距今 ≈ 27572s → 启发式 TTL ≈ 2757s ≈ 46 分钟(窗口内重启命中陈旧副本)。
  • curl -H "Cache-Control: no-cache"200 正常 → 客户端带 no-cache 即不再复用陈旧副本,证明 C1 修复方向正确
  • ?_t=Date.now() 的 URL 经 new URL() 解析合法(pathname 正确、search 为 _t=...)→ C2 修复方向正确

3. 设计方案(最终版 · 已整合 7 维审计)

设计原则:精准根治 + 配套必补项齐全 + 不过度设计。 改动分两类——核心(绕过缓存层,根治)辅助(主动刷新体验);其中辅助项含 5 个必补配套,否则功能/可靠性打折(详见第 4 节审计结论)。

3.1 核心(根治"重启/删除才有效")—— 绕过缓存层

目标:列表/分类请求永远拿最新,不依赖删除缓存目录。

  • C1 请求头禁用缓存

    • fetchWithRetry 对数据 JSON 请求加 headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }
    • 主进程 electron/main/index.ts:653onBeforeSendHeaders 中,仅对 applist.json / categories.json / priority-config.json 路径注入 Cache-Control: no-cachescope 严格过滤,绝不影响 cdn.d.store... 图标/截图加速)。双保险覆盖所有请求路径。
  • C2 URL 版本 bust(穿透 CDN 边缘缓存最稳手段)

    • 对上述数据请求 URL 追加 ?_t=${Date.now()},使每次 URL 不同,CDN 边缘与 Chromium 均无缓存可命中。
    • 代价:每次穿透 CDN,但列表文件小、频率低(启动/聚焦/手动),可接受。
    • 兼容性确认项:上线前需在测试环境确认 erotica.spark-app.store 的 CDN 对含 query 的静态路径行为正常(极少数 CDN 有特殊规则);若有异常,回退为仅依赖 no-cache 或 path 版本戳。

3.2 辅助(主动刷新体验)—— 让用户无需重启即拿最新

前提:3.1 已绕过缓存层。否则刷新也拿不到新数据。

  • A1 loadApps 幂等重建(必做,刷新安全前提)

    • App.vue:3216apps.value.push 改为:每个分类结果收集进 Map<pkgname, App>(复用现有 hybrid 合并逻辑),该分类加载完即 apps.value = [...map.values()]——保留增量提交(首个分类成功即关遮罩,不回退首屏体验),同时对所有模式(含 spark-only/apm-only)去重,反而比现状更稳。
  • A2 refreshAppData() 编排

    • 提取 await loadCategories()(本身重建安全,App.vue:2946+ 重跑改造后的 loadApps()。覆盖主因与次要因素(新分类一并刷新)。
  • A3 两个刷新入口

    • 窗口聚焦(经主进程,非渲染 window focus:主进程 win.on('focus')webContents.send('window-focus') → 渲染监听调用 refreshAppData()Electron 无边框窗口的渲染 window focus 事件不一定可靠,必须走 IPC 才稳)。
    • 手动刷新按钮:工具栏/侧边栏加刷新图标,调用 refreshAppData()

3.3 必补配套(5 项,缺一不可)

编号 维度 配套项 解决风险
P1 功能正确 A1 改造时保留每分类增量提交 + 非 hybrid 也按 pkgname 去重 防首屏回退、防重复应用
P2 可靠性 refreshAppData/loadApps串行化守卫in-flight 合并或 isRefreshing 标志) 防焦点/手动/启动并发竞态覆盖(loadApps 当前无锁,已确认)
P3 可靠性 主进程 win.on('focus')→IPC 而非渲染 window focus 防无边框窗口 focus 事件不可靠导致刷新静默失效
P4 性能 聚焦刷新加防抖(~3s+ 节流 防切窗口抖动堆叠请求、穿透 CDN 放大流量
P5 可观测 refreshAppData 入口打印 [AppData] 触发刷新 (reason=focus|manual)applist 加载后记录条目数变化(1200 → 1201 便于线上诊断"刷新是否真生效 / 新应用是否进内存"

3.4 可选

  • O1 loadTabApps TTL 失效App.vue:3098 强缓存改"超过 TTL(如 10min)才重拉"。因 all 搜索主路径已由 3.1+3.2 覆盖,可后置。

4. 7 维专业审计结论(改动影响预评估)

维度 影响评估 关键结论
1. 功能正确性 改善明确;A1/C2 改造需连带处理增量提交与去重(P1) 根治"陈旧列表",但 C 改动需保增量体验
2. 性能 C2 每次穿透 CDN,开销小;唯一主要负向是聚焦频繁触发 需 P4 防抖;否则成主要性能负向
3. 安全性 no-cache/时间戳 bust 中性;scope 必须限数据路径 不全局注入,不影响图标加速
4. 可靠性 两处真实风险:loadApps 无并发锁(竞态)、渲染 focus 不可靠 需 P2 串行化 + P3 IPC focus
5. 兼容性 no-cache/query 对所有 CDN/HTTP 通用;仅需一次 CDN 行为确认(C2) 兼容性良好
6. 可维护性 refreshAppData 抽出后逻辑集中、易测;增量代码小 维护性改善
7. 可观测/可测 当前刷新路径无专属日志;应补 P5 便于上线诊断

总体:方案根治有效,性能/安全负向可控;3 个必补配套(P1/P2/P3+ 2 个强建议(P4/P5 须一并实现,否则功能正确性/可靠性打折。


5. 方案落地可行性验证(代码核对 · 测试,不改功能代码)

本节为在动手前对方案各项做的代码级可行性核对(未修改任何源码),确认可实现性与需细化点。

5.1 逐项核对结论

代码事实(已核对) 可行性 需细化点
C1 主进程 no-cache 注入 onBeforeSendHeaderselectron/main/index.ts:653 注册,可拿 details.url(完整 URL,含 .../applist.json 等)。用 url.includes('/applist.json')||includes('/categories.json')||includes('/priority-config.json') 过滤注入即可 可行 scope 必须限三路径,不影响 cdn.d.store... 图标
C2 URL bust 数据请求三入口(loadApps App.vue:3203loadTabApps 3133/3151loadCategories 2925均走 fetchWithRetry;但 loadPriorityConfigstoreConfig.ts:84)走独立 priorityConfigAxios,不经过 fetchWithRetry 可行 bust 须覆盖两个 axios 实例;否则 priority-config.json 仍可能陈旧(影响 auto 策略,非搜索主路径但同源)
A1 loadApps Map 幂等重建 当前 App.vue:3216 apps.value.push,在 Promise.all(3190) 的 category→origins 异步循环内。改:函数内声明跨分类 Map<pkgname,App>,每分类 origins 完成后 apps.value = [...map.values()] 可行 增量提交须保留在每个分类处理末尾(非等全部完成),否则首屏回退;Map 按 pkgname 作 key 天然覆盖 spark-only/apm-only 去重
A2 refreshAppData 编排 新函数 = await loadCategories() + 重跑改造后 loadApps();与 loadApps 同作用域即可 可行 建议 onMounted 也改调 refreshAppData 而非裸 loadApps,统一入口
A3 + P3 主进程 focus→IPC 主进程 win 为模块级 let win(electron/main/index.ts:122)createWindowmainWindow(418)。mainWindow.on('focus', () => mainWindow.webContents.send('appdata-window-focus')) 可靠(主进程事件,不受无边框影响) 可行 渲染侧 window.ipcRenderer.on('appdata-window-focus', ...) 调用 refreshAppDatapreload 无需改动(通用 on 通道已暴露)
P2 串行化守卫 loadApps 当前无并发锁loading.value 仅遮罩),已确认 可行 refreshAppData 内加 refreshInFlight Promise 合并:if (inFlight) return inFlight; inFlight=(async()=>{...})().finally(()=>inFlight=null)
P4 防抖 focus 抖动会快速多次触发 可行 P2 的 in-flight 合并已天然防抖首跑;再叠加 time-based 节流(如 10s 内仅一次)更稳
P5 刷新日志 既有 Pino logger(如 App.vue:3205 logger.info('加载分类...') 可行 refreshAppData 入口 [AppData] 触发刷新 reason=...loadApps 完成 [AppData] 应用数 X → YY=map.size

5.2 关键发现(影响方案完整性)

  1. C1 主进程注入是 C2 的兜底:主进程 onBeforeSendHeaders 注入 no-cache所有经 Chromium 的请求(含 priorityConfigAxios 的 XHR)生效;即便 C2 漏给 priorityConfigAxios 加 bust,C1 也能保证其不读缓存。建议 C1 为主、C2 为辅,两者互补而非二选一。
  2. C2 必须显式覆盖 priorityConfigAxios:因其独立于 fetchWithRetry,要在 storeConfig.ts:84priorityConfigAxios.get(configPath) 处单独加 bust(或在 priorityConfigAxios 实例层加请求拦截器统一加 ?_t)。否则 auto 策略配置可能陈旧。
  3. loadApps 异步嵌套:当前 categoriesList.map(async category => origins.map(async mode => ...)) 嵌套异步;A1 改造时,需把内层改为 await Promise.all(origins.map(...)) 后在分类回调末尾提交 apps.value,确保"每分类增量提交"。
  4. 下游无回归apps.value 整体替换会触发 Vue 响应式,baseApps/filteredApps/排行均依赖它,赋值即刷新,无兼容问题。
  5. preload 零改动:新 IPC 通道 appdata-window-focus 直接 send/on,复用已暴露的通用 ipcRenderer,不需改 contextBridge

5.3 测试结论

  • 全部 8 个方案项(C1/C2/A1/A2/A3 + P1~P5)均可实现,无不可落地项。
  • 落地前须落实 5 个细化点(5.1 表"需细化点" + 5.2 关键发现),其中 C2 双 axios 覆盖C1 兜底关系 是最易被遗漏、却影响完整性的两点。
  • 风险等级:均为低~中,无高危阻塞;P2(竞态)与 A3 的 IPC 可靠性属"不做则功能打折"的必补项,已在方案中标明。

5.4 实际效果验证(2026-08-11,本机可执行部分已实测)

用户要求验证"实际效果"。区分两类验证:

  • (a) 机理实证(本机已完成):用 curl/Node 验证根因与修复方向正确性,无需 GUI/服务端改动。
  • (b) 端到端实证(需真机+服务端上架新应用,本机无法完成):见第 7 节,须由用户在真实环境执行。

(a) 已完成的本机实证:

  1. curl -sI 远程 applist.jsoncache-control 头、server: nginx → 证实 Chromium 启发式缓存为真因(非 CDN 主动缓存)。
  2. Node 计算启发式 TTL ≈ 46 分钟 → 量化"重启仍陈旧"的窗口。
  3. ?_t=Date.now() URL 经 new URL() 解析合法 → C2 方向正确。
  4. curl -H "Cache-Control: no-cache" 仍 200 → 客户端带 no-cache 即绕过陈旧副本,C1 方向正确。
  5. 串行化守卫 / Map 幂等去重 / focus→IPC 逻辑已在 5.1 核对,均为纯逻辑可单测(实现后补单测即闭环)。

(b) 尚待真机验证(非本机能力范围):

  • 需在服务端上架一个真实新应用(最好落入已存在分类,隔离"新分类"变量)。
  • 安装修复包后,不删缓存、不重启 → 聚焦/刷新 → 搜到新应用(验证 C1/C2 绕过启发式缓存)。
  • 重启不清缓存 → 首屏即显示(验证重启不再拿旧数据)。
  • 对照旧包同样操作需删 Cache 才有效 → 根因闭合。
  • 说明:本机为无显示环境(headless Linux),且无法操作远端商店后台上架,故 (b) 必须用户在真实环境执行;但根因机理已由 (a) 实证闭合,修复方向确凿

6. 明确不做(避免过度设计)

  • 定时轮询(后台常驻网络/CPU 开销,用户未要求实时)。
  • 本地磁盘离线缓存(问题不在离线,反而引入陈旧风险)。
  • ETag / 304 协商缓存(axios 不过系统缓存层,且已有 no-cache + bust 更稳;除非确认代理忽略请求头才需要)。
  • 全局 Cache-Control 注入到所有请求(仅对数据 JSON 路径注入,避免影响图标/截图等静态资源加速)。

6. 验证方法

验证分工:步骤 1(单测)随实现完成;步骤 2–5(端到端)需服务端上架新应用 + 真实 GUI 环境,由用户在真机执行(本机 headless 无法完成)。

  1. 单元/逻辑loadApps 幂等化后补测——重跑两次 apps.value 长度不变、无重复 pkgnamerefreshAppData 串行化 + 防抖单测。
  2. 集成(根因闭合):服务端上架新应用(落入已存在分类,隔离"新分类"变量)。
  3. 安装修复包,不删缓存、不重启 → 聚焦窗口 / 点刷新 → 应搜到新应用(证明缓存已绕过,P5 日志应显示条目数 +1)。
  4. 重启应用(不清缓存)→ 首屏即显示新应用(证明 no-cache + bust 生效,重启不再拿旧数据)。
  5. 对照旧包:同样操作需"删除 ~/.config/spark-store/Cache 才有效"——确认根因闭合、修复生效。

注:机理层面已由第 5.4 节本机 curl/Node 实证闭合(nginx 无 CC 头 → Chromium 启发式缓存 ≈46min;C1/C2 方向正确),故修复方向确凿,端到端仅作最终确认。


附:历次分析演进

  • 第一轮:怀疑 ~/.cache 本地应用列表文件缓存 → 核查排除(无磁盘列表缓存,搜索基于内存)。
  • 第二轮:深入确认"运行期不刷新"为主因(loadApps 仅启动一次、append 无去重、loadTabApps 强缓存、categories 启动一次)。
  • 第三轮:结合"重启无效、删除才行"反馈,定位为 Chromium 磁盘缓存 + CDN 边缘缓存双重陈旧,且代码零缓存失效机制;修正上轮结论——重启无效的根因是缓存层喂旧数据,而非单纯内存不刷新。
  • 第四轮(方案验证):按用户要求"先测试不改代码",对最终方案的 8 项(C1/C2/A1/A2/A3 + P1~P5)做代码级可行性核对,确认全部可落地;补充 5 个细化点(C2 须覆盖 priorityConfigAxios 独立实例、C1 主进程注入为兜底、loadApps 异步嵌套改造、下游无回归、preload 零改动),写入第 5 节。方案待用户审核后决定是否实现。

8. 附加修复:下载日志断层(下载卡在"正在获取 Metalink"突兀退出)

8.1 现象(用户反馈)

下载应用时日志停在:

[13:57:09] 开始下载...
[13:57:09] 正在获取 Metalink 文件: amd64-store/office/com.qianwen.otohime/com.qianwen.otohime_3.7.5.145_amd64.deb.metalink

随后界面无过渡直接结束(状态变 failed / 任务消失),中间无任何失败原因,体验突兀。

8.2 根因

下载流程在 electron/main/backend/install-manager.tsrunDownloadPhase

  1. Metalink 下载请求失败无前端日志await axios.get(...) 抛错时,原代码未 sendLog,错误只在主进程 logger.error 静默记录;错误向上抛到外层 catch → 直接发 install-complete {success:false},渲染端日志面板永远停在"正在获取 Metalink"。
  2. aria2c 拉起失败也无日志child.on("error")reject(err),渲染端同样看不到原因。
  3. 渲染端 install-complete 失败分支不写 logssrc/modules/processInstall.ts 收到失败时只改 status="failed",未把 log.message 的失败原因写入 downloadObj.logs,导致日志卡在最后一条中间状态。

三者叠加:任一环节失败,UI 都在"正在获取 Metalink"后突兀结束且无原因。

8.3 修复(已提交)

  • F1(主进程 Metalink 失败日志)axios.get 用 try/catch 包裹,失败时 sendLog("获取 Metalink 失败: <reason>")throw,保证渲染端能显示原因。Metalink 写入 finish 回调补 sendLog("Metalink 文件下载完成")
  • F2aria2c 拉起失败日志)child.on("error")sendLog("aria2c 启动失败: <msg>")
  • F3(渲染端失败原因入日志)install-completeelse 分支解析 log.message 中的 message 字段,push 一条 下载失败: <reason>downloadObj.logs;成功分支补 下载完成

改动文件:

  • electron/main/backend/install-manager.tsF1、F2
  • src/modules/processInstall.tsF3

注:本修复仅改善"失败可见性",不改变下载/重试/安装逻辑本身;UI 不再突兀卡在中间日志。

8.4 验证边界

本机 headless 无法真实触发 aria2c 下载与 GUI 日志面板;已通过 vue-tsc 类型检查与 read_lints 0 错误。真实失败场景(如 Metalink 404、网络中断)下的日志连贯性需用户在真机验证。