mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 13:40:11 +08:00
fix(审计修复): 缓存穿透TTL共享 + 预刷新网络/超时 + 最大化/APM优先设计注释
- 新增 cacheBusterInterceptor.ts:createCacheBusterInterceptor(ttl=5min) 带 TTL 复用戳, 解决同会话频繁击穿缓存(B1)并将 App.vue/storeConfig 两处重复逻辑合并为共享工厂(I3) - storeConfig.ts: priorityConfigAxios 改用共享拦截器;HYBRID_DEFAULT_PRIORITY 加注释 明确「主推 APM 是既定产品方向」(B2,非缺陷) - App.vue: axiosInstance 改用共享拦截器(带 TTL) - index.ts: 预刷新 startSourcePreRefreshOnce 加 net.isOnline() 检查 + 60s 超时兜底, 避免离线/卡死触发 pkexec 弹窗困惑(I1);createWindow 恢复逻辑加注释明确 「最大化后恢复重置为默认尺寸是有意设计,非缺陷」(I2) - vue-tsc + eslint 通过
This commit is contained in:
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
spark-store (5.2.1.18-test) UNRELEASED; urgency=medium
|
spark-store (5.2.1.19-test) UNRELEASED; urgency=medium
|
||||||
|
|
||||||
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
|
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
|
||||||
|
|
||||||
|
|||||||
+26
-4
@@ -4,6 +4,7 @@ import {
|
|||||||
ipcMain,
|
ipcMain,
|
||||||
Menu,
|
Menu,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
|
net,
|
||||||
shell,
|
shell,
|
||||||
Tray,
|
Tray,
|
||||||
nativeTheme,
|
nativeTheme,
|
||||||
@@ -437,10 +438,15 @@ async function createWindow() {
|
|||||||
});
|
});
|
||||||
win = mainWindow;
|
win = mainWindow;
|
||||||
|
|
||||||
// 不再自动恢复最大化状态(避免无法还原);启动即居中显示,过大窗口已回退默认尺寸
|
// 设计意图(非缺陷,勿改为自动恢复 maximized):
|
||||||
|
// 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过
|
||||||
|
// OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、
|
||||||
|
// 还原按钮失效」的体验问题;故最大化/全屏状态在关闭后统一回退为默认尺寸并居中,
|
||||||
|
// 放大/还原交由标题栏按钮由用户主动控制。WindowState 仍保存 maximized 字段
|
||||||
|
// (供需要该信息的场景读取),但恢复阶段有意不调用 win.maximize()。
|
||||||
logger.info(
|
logger.info(
|
||||||
{ saved, restoredWidth, restoredHeight, oversized },
|
{ saved, restoredWidth, restoredHeight, oversized },
|
||||||
"已恢复窗口状态(过大窗口回退默认尺寸并居中)",
|
"已恢复窗口状态(过大窗口回退默认尺寸并居中;最大化状态有意不自动恢复)",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复
|
// 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复
|
||||||
@@ -687,6 +693,7 @@ app.whenReady().then(() => {
|
|||||||
const PRE_REFRESH_BACKOFF_MS = [2000, 4000, 8000];
|
const PRE_REFRESH_BACKOFF_MS = [2000, 4000, 8000];
|
||||||
const PRE_REFRESH_MAX_RETRIES = 3;
|
const PRE_REFRESH_MAX_RETRIES = 3;
|
||||||
const PRE_REFRESH_FALLBACK_MS = 15_000; // 渲染信号未到达时的兜底,15s 后也跑
|
const PRE_REFRESH_FALLBACK_MS = 15_000; // 渲染信号未到达时的兜底,15s 后也跑
|
||||||
|
const PRE_REFRESH_TIMEOUT_MS = 60_000; // 单次预刷新整体超时,避免 pkexec 卡死挂起
|
||||||
let preRefreshStarted = false;
|
let preRefreshStarted = false;
|
||||||
|
|
||||||
// 按重试次数取退避毫秒(越界时回退到最大间隔)
|
// 按重试次数取退避毫秒(越界时回退到最大间隔)
|
||||||
@@ -694,13 +701,28 @@ const getPreRefreshBackoffDelay = (attempt: number): number =>
|
|||||||
PRE_REFRESH_BACKOFF_MS[attempt - 1] ??
|
PRE_REFRESH_BACKOFF_MS[attempt - 1] ??
|
||||||
PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1];
|
PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1];
|
||||||
|
|
||||||
// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)
|
// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)。
|
||||||
|
// 设计意图:单次会话仅预热一次(preRefreshStarted 置 true 后不再重置),
|
||||||
|
// 避免用户在更新中心 Tab 间快速切换时重复触发 pkexec 弹窗造成困惑。
|
||||||
const startSourcePreRefreshOnce = (attempt = 1): void => {
|
const startSourcePreRefreshOnce = (attempt = 1): void => {
|
||||||
if (preRefreshStarted) return;
|
if (preRefreshStarted) return;
|
||||||
preRefreshStarted = true;
|
preRefreshStarted = true;
|
||||||
|
|
||||||
const run = (): void => {
|
const run = (): void => {
|
||||||
runSystemUpdateSources("both")
|
// 网络可用性前置检查:离线时不发起提权刷新(pkexec 弹窗无意义且困惑)
|
||||||
|
if (!net.isOnline()) {
|
||||||
|
logger.info("[UpdateCenter] 预刷新跳过:当前离线");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error("pre-refresh timeout")),
|
||||||
|
PRE_REFRESH_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.race([runSystemUpdateSources("both"), timeoutPromise])
|
||||||
.then((results) => {
|
.then((results) => {
|
||||||
console.log("[UpdateCenter] pre-refresh done:", results);
|
console.log("[UpdateCenter] pre-refresh done:", results);
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-16
@@ -344,6 +344,7 @@ import {
|
|||||||
getHybridDefaultOrigin,
|
getHybridDefaultOrigin,
|
||||||
loadPriorityConfig,
|
loadPriorityConfig,
|
||||||
} from "./global/storeConfig";
|
} from "./global/storeConfig";
|
||||||
|
import { createCacheBusterInterceptor } from "./global/cacheBusterInterceptor";
|
||||||
import {
|
import {
|
||||||
downloads,
|
downloads,
|
||||||
removeDownloadItem,
|
removeDownloadItem,
|
||||||
@@ -433,22 +434,10 @@ const axiosInstance = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// C2:数据 JSON(applist / categories / sidebar-config 等)追加 ?_t 版本戳,
|
// C2:数据 JSON(applist / categories / sidebar-config 等)追加 ?_t 版本戳,
|
||||||
// 使每次 URL 不同,穿透 CDN 边缘缓存,确保返回最新列表。
|
// 穿透 CDN 边缘缓存,确保返回最新列表。复用共享缓存穿透拦截器(带 TTL 复用戳,
|
||||||
// 与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底(C1 已对 Chromium 磁盘缓存生效)。
|
// 避免同会话频繁击穿缓存)。与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底。
|
||||||
axiosInstance.interceptors.request.use((config) => {
|
// 这是有意的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。
|
||||||
if (
|
axiosInstance.interceptors.request.use(createCacheBusterInterceptor());
|
||||||
config.method?.toLowerCase() === "get" &&
|
|
||||||
typeof config.url === "string"
|
|
||||||
) {
|
|
||||||
// 按去除 query 的 pathname 判断,兼容 C2 自身追加的 ?_t= 及任何既有 query
|
|
||||||
const reqPath = config.url.split("?")[0];
|
|
||||||
if (reqPath.endsWith(".json")) {
|
|
||||||
const sep = config.url.includes("?") ? "&" : "?";
|
|
||||||
config.url = `${config.url}${sep}_t=${Date.now()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchWithRetry = async <T,>(
|
const fetchWithRetry = async <T,>(
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存穿透拦截器工厂:为所有 .json GET 请求追加 ?_t=${timestamp} 版本戳,
|
||||||
|
* 穿透 CDN/浏览器缓存,确保新应用上架后能立即被搜到(解决"上架后搜不到")。
|
||||||
|
*
|
||||||
|
* 为避免同一次会话内每次请求都生成新戳导致缓存完全失效(频繁重复请求、浪费带宽),
|
||||||
|
* 引入 TTL:在 ttlMs 窗口内复用同一时间戳;超过窗口才刷新。
|
||||||
|
* 默认 5 分钟,兼顾"及时感知新数据"与"不过度击穿缓存"。
|
||||||
|
*
|
||||||
|
* 注意:这是有意为之的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。
|
||||||
|
*/
|
||||||
|
export function createCacheBusterInterceptor(ttlMs = 5 * 60 * 1000) {
|
||||||
|
let cachedTimestamp: number | null = null;
|
||||||
|
|
||||||
|
return (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
||||||
|
if (
|
||||||
|
config.method?.toLowerCase() === "get" &&
|
||||||
|
typeof config.url === "string"
|
||||||
|
) {
|
||||||
|
const reqPath = config.url.split("?")[0];
|
||||||
|
if (reqPath.endsWith(".json")) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedTimestamp === null || now - cachedTimestamp > ttlMs) {
|
||||||
|
cachedTimestamp = now;
|
||||||
|
}
|
||||||
|
const sep = config.url.includes("?") ? "&" : "?";
|
||||||
|
config.url = `${config.url}${sep}_t=${cachedTimestamp}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { App, StoreMode } from "./typedefinition";
|
import type { App, StoreMode } from "./typedefinition";
|
||||||
|
import { createCacheBusterInterceptor } from "./cacheBusterInterceptor";
|
||||||
|
|
||||||
export const APM_STORE_BASE_URL: string =
|
export const APM_STORE_BASE_URL: string =
|
||||||
import.meta.env.VITE_APM_STORE_BASE_URL || "";
|
import.meta.env.VITE_APM_STORE_BASE_URL || "";
|
||||||
@@ -13,18 +14,9 @@ const priorityConfigAxios = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// C2:priority-config.json 走独立 axios 实例(不经过 axiosInstance),
|
// C2:priority-config.json 走独立 axios 实例(不经过 axiosInstance),
|
||||||
// 同样追加 ?_t 版本戳以穿透 CDN 边缘缓存,与 C1 的 no-cache 注入互为兜底。
|
// 复用共享缓存穿透拦截器(带 TTL 复用戳,避免同会话频繁击穿缓存)。
|
||||||
priorityConfigAxios.interceptors.request.use((config) => {
|
// 与 C1(axiosInstance 的拦截器)为同一工厂生成,确保穿透策略与 TTL 行为一致。
|
||||||
if (config.method?.toLowerCase() === "get" && typeof config.url === "string") {
|
priorityConfigAxios.interceptors.request.use(createCacheBusterInterceptor());
|
||||||
// 按去除 query 的 pathname 判断,兼容 C2 自身追加的 ?_t= 及任何既有 query
|
|
||||||
const reqPath = config.url.split("?")[0];
|
|
||||||
if (reqPath.endsWith(".json")) {
|
|
||||||
const sep = config.url.includes("?") ? "&" : "?";
|
|
||||||
config.url = `${config.url}${sep}_t=${Date.now()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const APM_STORE_STATS_BASE_URL: string =
|
export const APM_STORE_STATS_BASE_URL: string =
|
||||||
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
|
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
|
||||||
@@ -51,7 +43,10 @@ export const showApmInstallDialog = ref(false);
|
|||||||
|
|
||||||
export const currentStoreMode = ref<StoreMode>("hybrid");
|
export const currentStoreMode = ref<StoreMode>("hybrid");
|
||||||
|
|
||||||
// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用)
|
// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用)。
|
||||||
|
// 设计意图:社区版当前主推 APM 来源(星火 APM 为新架构、deb 为传统来源),
|
||||||
|
// 故默认优先级设为 "apm"。非临时回归,属既定产品方向;如后续调整需产品确认。
|
||||||
|
// TODO: 产品确认默认 APM 优先是否长期保持。
|
||||||
export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm";
|
export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm";
|
||||||
|
|
||||||
// 优先级规则配置接口
|
// 优先级规则配置接口
|
||||||
|
|||||||
Reference in New Issue
Block a user