- 主进程 Metalink 下载请求失败显式 sendLog 并回传渲染端(原仅静默 logger.error) - aria2c 拉起失败补 sendLog 日志 - Metalink 写入完成补 sendLog 提示 - 渲染端 install-complete 失败分支将错误原因写入 logs,并补成功/失败终止日志 - 配套: 缓存修复(C1 主进程 no-cache + C2 axios ?_t 版本戳) 已在上一提交实现 - 文档 docs/app-list-cache-analysis.md 新增第8节下载日志断层修复方案
21 KiB
应用列表缓存导致"新应用上架后搜不到"的根因分析与设计方案
App List Cache Analysis & Design (spark-store) 整理日期:2026-08-11 背景分支:
Erotica文档涵盖:① 列表缓存根因与修复(C1/C2);② 下载日志断层修复(附加)
0. 现象(用户反馈)
- 服务端上新应用后,在商店内搜不到该应用。
- 直觉认为是
~/.cache下的本地缓存导致,但实测并非如此。 - 进一步反馈:重启应用也无效,只有"删除缓存"才行——这指向缓存层在重启后仍返回陈旧数据。
1. 缓存层逐层排查
| 缓存层 | 是否存在 | 证据 | 是否导致本问题 |
|---|---|---|---|
① 内存(渲染进程 apps 数组) |
是 | 搜索基于内存 baseApps(App.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 仍可能返回边缘陈旧副本 |
请求侧缓存控制现状(代码事实)
fetchWithRetry(App.vue:432):axiosInstance.get(url, { signal }),无任何Cache-Control头。axiosInstance(App.vue:427):仅baseURL+timeout,无 headers。- 主进程
onBeforeSendHeaders(electron/main/index.ts:653):只注入User-Agent,未注入Cache-Control: no-cache。 loadApps(App.vue:3216):apps.value.push(...)—— append 模式、无去重、无重置,重跑会累积重复。loadTabApps(App.vue:3098):if (tabApps.value[entryId]) return;—— 已加载分类强缓存,永不重拉。loadCategories(App.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 / Node,2026-08-11)
curl -sI远程applist.json:HTTP/2 200、server: nginx、etag: "6a7a764e-2f9d8"、last-modified: Tue, 11 Aug 2026 01:09:34 GMT,无cache-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:653的onBeforeSendHeaders中,仅对applist.json/categories.json/priority-config.json路径注入Cache-Control: no-cache(scope 严格过滤,绝不影响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 版本戳。
- 对上述数据请求 URL 追加
3.2 辅助(主动刷新体验)—— 让用户无需重启即拿最新
前提:3.1 已绕过缓存层。否则刷新也拿不到新数据。
-
A1
loadApps幂等重建(必做,刷新安全前提)- 将
App.vue:3216的apps.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()。
- 窗口聚焦(经主进程,非渲染 window focus):主进程
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
loadTabAppsTTL 失效: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 注入 | onBeforeSendHeaders 在 electron/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:3203、loadTabApps 3133/3151、loadCategories 2925)均走 fetchWithRetry;但 loadPriorityConfig(storeConfig.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),createWindow 内 mainWindow(418)。mainWindow.on('focus', () => mainWindow.webContents.send('appdata-window-focus')) 可靠(主进程事件,不受无边框影响) |
✅ 可行 | 渲染侧 window.ipcRenderer.on('appdata-window-focus', ...) 调用 refreshAppData;preload 无需改动(通用 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 → Y(Y=map.size) |
5.2 关键发现(影响方案完整性)
- C1 主进程注入是 C2 的兜底:主进程
onBeforeSendHeaders注入no-cache对所有经 Chromium 的请求(含priorityConfigAxios的 XHR)生效;即便 C2 漏给priorityConfigAxios加 bust,C1 也能保证其不读缓存。建议 C1 为主、C2 为辅,两者互补而非二选一。 - C2 必须显式覆盖
priorityConfigAxios:因其独立于fetchWithRetry,要在storeConfig.ts:84的priorityConfigAxios.get(configPath)处单独加 bust(或在priorityConfigAxios实例层加请求拦截器统一加?_t)。否则 auto 策略配置可能陈旧。 - loadApps 异步嵌套:当前
categoriesList.map(async category => origins.map(async mode => ...))嵌套异步;A1 改造时,需把内层改为await Promise.all(origins.map(...))后在分类回调末尾提交apps.value,确保"每分类增量提交"。 - 下游无回归:
apps.value整体替换会触发 Vue 响应式,baseApps/filteredApps/排行均依赖它,赋值即刷新,无兼容问题。 - 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) 已完成的本机实证:
curl -sI远程applist.json:无cache-control头、server: nginx→ 证实 Chromium 启发式缓存为真因(非 CDN 主动缓存)。- Node 计算启发式 TTL ≈ 46 分钟 → 量化"重启仍陈旧"的窗口。
?_t=Date.now()URL 经new URL()解析合法 → C2 方向正确。curl -H "Cache-Control: no-cache"仍 200 → 客户端带 no-cache 即绕过陈旧副本,C1 方向正确。- 串行化守卫 / 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 无法完成)。
- 单元/逻辑:
loadApps幂等化后补测——重跑两次apps.value长度不变、无重复 pkgname;refreshAppData串行化 + 防抖单测。 - 集成(根因闭合):服务端上架新应用(落入已存在分类,隔离"新分类"变量)。
- 安装修复包,不删缓存、不重启 → 聚焦窗口 / 点刷新 → 应搜到新应用(证明缓存已绕过,P5 日志应显示条目数 +1)。
- 重启应用(不清缓存)→ 首屏即显示新应用(证明 no-cache + bust 生效,重启不再拿旧数据)。
- 对照旧包:同样操作需"删除
~/.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.ts 的 runDownloadPhase:
- Metalink 下载请求失败无前端日志:
await axios.get(...)抛错时,原代码未sendLog,错误只在主进程logger.error静默记录;错误向上抛到外层 catch → 直接发install-complete {success:false},渲染端日志面板永远停在"正在获取 Metalink"。 - aria2c 拉起失败也无日志:
child.on("error")仅reject(err),渲染端同样看不到原因。 - 渲染端
install-complete失败分支不写 logs:src/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 文件下载完成")。 - F2(aria2c 拉起失败日志):
child.on("error")补sendLog("aria2c 启动失败: <msg>")。 - F3(渲染端失败原因入日志):
install-complete的else分支解析log.message中的message字段,push 一条下载失败: <reason>到downloadObj.logs;成功分支补下载完成。
改动文件:
electron/main/backend/install-manager.ts(F1、F2)src/modules/processInstall.ts(F3)
注:本修复仅改善"失败可见性",不改变下载/重试/安装逻辑本身;UI 不再突兀卡在中间日志。
8.4 验证边界
本机 headless 无法真实触发 aria2c 下载与 GUI 日志面板;已通过 vue-tsc 类型检查与 read_lints 0 错误。真实失败场景(如 Metalink 404、网络中断)下的日志连贯性需用户在真机验证。