From e22f71d255282060096af1deb917f48a591e8da2 Mon Sep 17 00:00:00 2001 From: shenmo Date: Wed, 11 Nov 2026 20:03:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor(download-count):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=87=8F=E6=94=B9=E4=B8=BA=E8=AF=BB=E5=8F=96=20app.json/applis?= =?UTF-8?q?t.json=20=E7=9A=84=20DownloadTimes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载量不再逐个应用请求 download-times.txt(数千个应用 = 数千次额外请求), 改为直接读取服务器注入到目录/详情 JSON 的 DownloadTimes 字段。 - AppJson 新增 DownloadTimes,normalizeAppJson 映射为 app.downloadCount - useRanking 移除逐应用请求、并发批处理与 localStorage 缓存, 直接按已加载 apps 的 downloadCount 排序发布(零额外请求) - 详情页优先用目录数据的 downloadCount;缺失时(如已安装页兜底 App) 回退读取 app.json 的 DownloadTimes,并加请求序号防止旧响应覆盖 - modules/ranking 新增 parseDownloadTimes,统一处理数字/字符串/非法值 - 新增回归测试 ranking-download-count.test.ts - 更新《下载量统计接口规范》与 ranking 计划文档 验证:vue-tsc 0 error;新增 6 项测试通过;全套单测失败项与改动前 完全一致(35 项均为既有失败,无新增);eslint 改动文件无新增问题。 Signed-off-by: shenmo --- docs/ranking-honor-plan.md | 2 +- .../unit/ranking-download-count.test.ts | 135 ++++++++++++++++++ src/components/AppDetailModal.vue | 47 +++--- src/composables/useAppDetail.ts | 3 + src/composables/useCatalog.ts | 3 + src/composables/useRanking.ts | 114 ++------------- src/global/typedefinition.ts | 7 +- src/modules/ranking.ts | 15 ++ 下载量统计接口规范.md | 15 +- 9 files changed, 219 insertions(+), 122 deletions(-) create mode 100644 src/__tests__/unit/ranking-download-count.test.ts diff --git a/docs/ranking-honor-plan.md b/docs/ranking-honor-plan.md index 9c649175..a3661551 100644 --- a/docs/ranking-honor-plan.md +++ b/docs/ranking-honor-plan.md @@ -23,7 +23,7 @@ | 字段 | 来源 | 结论 | |------|------|------| -| `app.downloadCount` | 现有 `apmRanking` / `sparkRanking`(`App.vue` 1164–1250,异步逐应用拉 `download-times.txt`) | ✅ 直接复用,无需新增请求 | +| `app.downloadCount` | 现有 `apmRanking` / `sparkRanking`(由 `applist.json` 的 `DownloadTimes` 直接映射,无额外请求) | ✅ 直接复用,无需新增请求 | | `app.update` | `App.vue:886` 由 `appJson.Update` 映射,格式 `"2026-01-26 17:34:15"`,同格式可直接 `localeCompare` 倒序 | ✅ 列表已含,可直接排序 | | `app.contributor` | `App.vue:884` 由 `appJson.Contributor` 映射 | ✅ 列表已含,无需详情请求 | diff --git a/src/__tests__/unit/ranking-download-count.test.ts b/src/__tests__/unit/ranking-download-count.test.ts new file mode 100644 index 00000000..fe43858a --- /dev/null +++ b/src/__tests__/unit/ranking-download-count.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { normalizeAppJson } from "@/composables/useCatalog"; +import { loadRanking } from "@/composables/useRanking"; +import { + apps, + apmRanking, + rankingLoading, + sparkRanking, +} from "@/composables/useAppState"; +import { parseDownloadTimes } from "@/modules/ranking"; +import type { App, AppJson } from "@/global/typedefinition"; + +/** + * 下载量来源回归测试: + * 下载量取自 app.json / applist.json 的 DownloadTimes 字段, + * 不得再逐个应用请求 download-times.txt。 + */ + +const baseJson = (overrides: Partial): AppJson => ({ + Name: "App", + Version: "1.0.0", + Filename: "app_1.0.0_amd64.deb", + Torrent_address: "", + Pkgname: "app", + Author: "", + Contributor: "", + Website: "", + Update: "2026-01-01 00:00:00", + Size: "1M", + More: "", + Tags: "", + img_urls: "[]", + icons: "", + ...overrides, +}); + +describe("parseDownloadTimes", () => { + it("接受数字与数字字符串", () => { + expect(parseDownloadTimes(52210)).toBe(52210); + expect(parseDownloadTimes("4806")).toBe(4806); + expect(parseDownloadTimes(" 42 ")).toBe(42); + }); + + it("截断小数并拒绝非法值", () => { + expect(parseDownloadTimes(12.7)).toBe(12); + expect(parseDownloadTimes(undefined)).toBeUndefined(); + expect(parseDownloadTimes(null)).toBeUndefined(); + expect(parseDownloadTimes("")).toBeUndefined(); + expect(parseDownloadTimes("abc")).toBeUndefined(); + expect(parseDownloadTimes(-1)).toBeUndefined(); + }); +}); + +describe("normalizeAppJson", () => { + it("把 applist.json 的 DownloadTimes 映射为 downloadCount", () => { + const app = normalizeAppJson( + baseJson({ DownloadTimes: 9001 }), + "office", + "spark", + ); + expect(app.downloadCount).toBe(9001); + }); + + it("字段缺失时 downloadCount 为 undefined(不代表 0 次下载)", () => { + const app = normalizeAppJson(baseJson({}), "office", "apm"); + expect(app.downloadCount).toBeUndefined(); + }); +}); + +describe("loadRanking", () => { + const makeApp = ( + pkgname: string, + origin: "spark" | "apm", + downloadCount?: number, + ): App => ({ + name: pkgname, + pkgname, + version: "1.0.0", + filename: `${pkgname}.deb`, + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "tools", + origin, + currentStatus: "not-installed", + downloadCount, + }); + + beforeEach(() => { + apps.value = []; + apmRanking.value = []; + sparkRanking.value = []; + rankingLoading.value = false; + }); + + it("按 downloadCount 排序分来源发布,且不发网络请求", () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + apps.value = [ + makeApp("apm-low", "apm", 10), + makeApp("spark-low", "spark", 5), + makeApp("apm-high", "apm", 900), + makeApp("spark-high", "spark", 500), + makeApp("no-count", "apm"), + ]; + + loadRanking(); + + expect(apmRanking.value.map((a) => a.pkgname)).toEqual([ + "apm-high", + "apm-low", + "no-count", + ]); + expect(sparkRanking.value.map((a) => a.pkgname)).toEqual([ + "spark-high", + "spark-low", + ]); + expect(rankingLoading.value).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("应用列表为空时直接返回,不发布空榜", () => { + loadRanking(); + expect(apmRanking.value).toEqual([]); + expect(sparkRanking.value).toEqual([]); + }); +}); diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index e144fba1..4e33b975 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -580,6 +580,7 @@ import { tagPriorityStrategyRef, type TagPriorityStrategy, } from "../global/tagPriority"; +import { parseDownloadTimes } from "../modules/ranking"; // 评论功能暂时关闭 // import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity"; import type { App, AppReview, ReviewTags } from "../global/typedefinition"; @@ -846,28 +847,38 @@ const iconPath = computed(() => { // }); const downloadCount = ref(""); +// 请求序号:快速切换应用/来源时丢弃过期响应,避免旧应用的下载量覆盖新应用 +let downloadCountRequest = 0; -// 监听 app 变化,获取新app的下载量 +// 监听 app 变化,解析新 app 的下载量。 +// 优先使用目录数据(applist.json)已携带的 downloadCount; +// 仅当缺失(例如已安装页的兜底 App)才回退读取 app.json 的 DownloadTimes, +// 两者都不再请求 download-times.txt。 watch( () => displayApp.value, async (newApp) => { - if (newApp) { - downloadCount.value = ""; - try { - const arch = window.apm_store.arch || "amd64"; - const finalArch = - newApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`; - const url = `${APM_STORE_BASE_URL}/${finalArch}/${newApp.category}/${newApp.pkgname}/download-times.txt`; - const resp = await axios.get(url, { responseType: "text" }); - if (resp.status === 200) { - downloadCount.value = String(resp.data).trim(); - } else { - downloadCount.value = "N/A"; - throw new Error(`Unexpected response status: ${resp.status}`); - } - } catch (e) { - console.error("Failed to fetch download count", e); - } + const seq = ++downloadCountRequest; + downloadCount.value = ""; + if (!newApp) return; + if (typeof newApp.downloadCount === "number") { + downloadCount.value = String(newApp.downloadCount); + return; + } + try { + const arch = window.apm_store.arch || "amd64"; + const finalArch = + newApp.origin === "spark" ? `${arch}-store` : `${arch}-apm`; + const url = `${APM_STORE_BASE_URL}/${finalArch}/${encodeURIComponent( + newApp.category, + )}/${encodeURIComponent(newApp.pkgname)}/app.json`; + const resp = await axios.get(url); + if (seq !== downloadCountRequest) return; + const count = parseDownloadTimes( + (resp.data as { DownloadTimes?: unknown } | null)?.DownloadTimes, + ); + if (count !== undefined) downloadCount.value = String(count); + } catch (e) { + console.error("Failed to read download count from app.json", e); } }, { immediate: true }, diff --git a/src/composables/useAppDetail.ts b/src/composables/useAppDetail.ts index 2b9414d4..40efac90 100644 --- a/src/composables/useAppDetail.ts +++ b/src/composables/useAppDetail.ts @@ -34,6 +34,7 @@ import { buildReviewTags, } from "../modules/appIdentity"; import { loadFavoriteMetadataForDetail } from "./useFavorites"; +import { parseDownloadTimes } from "../modules/ranking"; import { axiosInstance } from "./useHttp"; import type { ReviewTags } from "../global/typedefinition"; import type { Ref } from "vue"; @@ -107,6 +108,8 @@ const fetchAppFromStore = async ( category, origin, currentStatus: "not-installed", + // app.json 同样携带下载次数,避免详情页再单独请求 download-times.txt + downloadCount: parseDownloadTimes(appJson.DownloadTimes), }; } catch (e) { // 组件卸载/详情关闭触发 abort 时静默返回,避免刷 AbortError 日志 diff --git a/src/composables/useCatalog.ts b/src/composables/useCatalog.ts index cdf3766f..bcc3136c 100644 --- a/src/composables/useCatalog.ts +++ b/src/composables/useCatalog.ts @@ -27,6 +27,7 @@ import { } from "./useAppState"; import { axiosInstance, fetchWithRetry, rootAbortController } from "./useHttp"; import { loadPriorityConfig, APM_STORE_BASE_URL } from "../global/storeConfig"; +import { parseDownloadTimes } from "../modules/ranking"; export const loadCategories = async () => { try { @@ -179,6 +180,8 @@ export const normalizeAppJson = ( category: category, origin: origin, currentStatus: "not-installed" as const, + // 下载量由 applist.json 直接携带,无需再逐个请求 download-times.txt + downloadCount: parseDownloadTimes(appJson.DownloadTimes), }); export const loadTabCategories = async () => { diff --git a/src/composables/useRanking.ts b/src/composables/useRanking.ts index 2c60436c..34807cf8 100644 --- a/src/composables/useRanking.ts +++ b/src/composables/useRanking.ts @@ -1,8 +1,9 @@ /** * useRanking —— 首页区域(links + 推荐列表入口)与下载排行榜数据。 * - * 从原 App.vue 原样搬移(loadHome / loadRanking / fetchDownloadCount / 缓存逻辑 / - * publishRanking),逻辑零改动。 + * 下载量直接复用目录接口(applist.json)携带的 `DownloadTimes` 字段, + * 由 normalizeAppJson 映射为 `App.downloadCount`,不再逐个应用请求 + * download-times.txt(数千个应用意味着数千次额外请求)。 * * 共享状态(apps / homeLinks / apmRanking / sparkRanking / rankingLoading / * homeLoading / homeError / storeFilter)来自 useAppState 单例。 @@ -18,120 +19,33 @@ import { rankingLoading, storeFilter, } from "./useAppState"; -import { rootAbortController } from "./useHttp"; import { APM_STORE_BASE_URL } from "../global/storeConfig"; -const DOWNLOAD_COUNT_CACHE_KEY = "spark-store:download-counts:v1"; -const DOWNLOAD_COUNT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时 - -interface CachedCount { - count: number; - ts: number; -} - -const downloadCountCache = new Map(); - -const cacheKey = (app: App) => `${app.origin}:${app.category}:${app.pkgname}`; - -const loadCacheFromStorage = () => { - try { - const raw = localStorage.getItem(DOWNLOAD_COUNT_CACHE_KEY); - if (!raw) return; - const data = JSON.parse(raw) as Record; - const now = Date.now(); - for (const [k, v] of Object.entries(data)) { - if (now - v.ts < DOWNLOAD_COUNT_CACHE_TTL_MS) { - downloadCountCache.set(k, v); - } - } - } catch { - // ignore corrupted cache - } -}; - -const saveCacheToStorage = () => { - try { - const obj: Record = {}; - downloadCountCache.forEach((v, k) => { - obj[k] = v; - }); - localStorage.setItem(DOWNLOAD_COUNT_CACHE_KEY, JSON.stringify(obj)); - } catch { - // ignore quota errors - } -}; - -const fetchDownloadCount = async (app: App): Promise => { - const key = cacheKey(app); - const cached = downloadCountCache.get(key); - if (cached) return cached.count; - const arch = window.apm_store.arch || "amd64"; - const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`; - try { - const resp = await fetch( - `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/download-times.txt`, - { signal: rootAbortController.signal }, - ); - if (!resp.ok) return 0; - const text = (await resp.text()).trim(); - const n = parseInt(text, 10); - const count = Number.isFinite(n) ? n : 0; - downloadCountCache.set(key, { count, ts: Date.now() }); - return count; - } catch { - return 0; - } -}; - const publishRanking = (results: App[]) => { + // 浅拷贝榜单条目,避免榜单与目录 apps 共享同一对象被下游误改 apmRanking.value = results .filter((a) => a.origin === "apm") .sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0)) - .slice(0, 10); + .slice(0, 10) + .map((a) => ({ ...a })); sparkRanking.value = results .filter((a) => a.origin === "spark") .sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0)) - .slice(0, 10); + .slice(0, 10) + .map((a) => ({ ...a })); }; -let rankingGeneration = 0; - -const loadRanking = async () => { +/** + * 下载排行:下载量已在 applist.json 加载阶段随应用写入,此处仅做排序与发布, + * 不发起任何网络请求。必须在 loadApps 完成(apps 已填充)之后调用。 + */ +const loadRanking = () => { if (apps.value.length === 0) return; - const gen = ++rankingGeneration; rankingLoading.value = true; - const all = apps.value.slice(); - const CONCURRENCY = 15; - const results: App[] = []; - for (let i = 0; i < all.length; i += CONCURRENCY) { - if (gen !== rankingGeneration) { - saveCacheToStorage(); - return; - } - const batch = all.slice(i, i + CONCURRENCY); - const settled = await Promise.all( - batch.map(async (app) => ({ - ...app, - downloadCount: await fetchDownloadCount(app), - })), - ); - results.push(...settled); - // 边拉边发:每批完成后立即发布增量排名 - if (gen === rankingGeneration) publishRanking(results); - } - if (gen !== rankingGeneration) { - saveCacheToStorage(); - return; - } + publishRanking(apps.value); rankingLoading.value = false; - saveCacheToStorage(); }; -// 启动时即加载本地缓存(无需等待 apps),二次启动首屏即可见缓存排行 -loadCacheFromStorage(); - -// 排行榜在 loadApps 全量完成后(onMounted)触发一次,确保 spark/apm 应用均已就绪 - const loadHome = async () => { homeLoading.value = true; homeError.value = ""; diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index 72a66996..0545f8d0 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -69,7 +69,8 @@ export interface DownloadItem { "More": "VSCode是一款非常牛逼的编辑器", "Tags": "community;ubuntu;deepin;uos;debian", "img_urls": "[\"https://cdn.d.store.deepinos.org.cn/store/development/code/screen_1.png\",\"https://cdn.d.store.deepinos.org.cn/store/development/code/screen_2.png\",\"https://cdn.d.store.deepinos.org.cn/store/development/code/screen_3.png\",\"https://cdn.d.store.deepinos.org.cn/store/development/code/screen_4.png\",\"https://cdn.d.store.deepinos.org.cn/store/development/code/screen_5.png\"]", - "icons": "https://cdn.d.store.deepinos.org.cn/store/development/code/icon.png" + "icons": "https://cdn.d.store.deepinos.org.cn/store/development/code/icon.png", + "DownloadTimes": 12345 */ export interface AppJson { // 原始数据 @@ -87,6 +88,8 @@ export interface AppJson { Tags: string; img_urls: string; // 注意:部分 json 里可能是字符串形式的数组 icons: string; + /** 下载次数,由服务器注入到 app.json / applist.json;可能缺失或为字符串数字 */ + DownloadTimes?: number | string; } export interface App { @@ -117,7 +120,7 @@ export interface App { viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal forceViewingOrigin?: boolean; // true 表示 viewingOrigin 为父组件显式指定(如从已安装页按特定来源打开),优先级高于用户标签策略;未设置或 false 时由标签策略决定默认展示 origins?: Array<"spark" | "apm">; // 实际安装来源集合(同时以 APM 与 Spark 安装时含两项) - downloadCount?: number; // 下载量(用于下载排行,由 download-times.txt 解析) + downloadCount?: number; // 下载量(用于下载排行,取自 applist.json / app.json 的 DownloadTimes 字段) } export interface UpdateAppItem { diff --git a/src/modules/ranking.ts b/src/modules/ranking.ts index 197bee53..e33f7ee9 100644 --- a/src/modules/ranking.ts +++ b/src/modules/ranking.ts @@ -8,6 +8,21 @@ import type { App } from "../global/typedefinition"; /** 榜单统一条数(需求确认:10 条) */ export const TOP_N = 10; +/** + * 解析服务器在 app.json / applist.json 中注入的 `DownloadTimes` 字段。 + * 该字段正常为数字,但历史数据/第三方镜像也可能是字符串甚至缺失, + * 统一收敛为「非负整数」;无法解析时返回 undefined,交由调用方决定是否回退。 + */ +export function parseDownloadTimes(raw: unknown): number | undefined { + const n = + typeof raw === "number" + ? raw + : typeof raw === "string" && raw.trim() !== "" + ? Number(raw.trim()) + : NaN; + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined; +} + /** 更新荣耀榜取「最近更新」应用的窗口大小(用于聚合这部分贡献者) */ export const RECENT_UPDATE_WINDOW = 30; diff --git a/下载量统计接口规范.md b/下载量统计接口规范.md index 0e6c471c..c07ea25c 100644 --- a/下载量统计接口规范.md +++ b/下载量统计接口规范.md @@ -1,6 +1,19 @@ # 下载量统计接口规范 -## 1. 获取下载次数 +## 0. 客户端读取方式(现行) + +客户端**不再**逐个应用请求 `download-times.txt`(数千个应用意味着数千次请求), +改为直接读取目录/详情 JSON 中服务器注入的 `DownloadTimes` 字段: + +- 列表与下载排行:`{SOURCE_URL}/{ARCH}/{CATEGORY}/applist.json` 每项的 `DownloadTimes` +- 详情页兜底:`{SOURCE_URL}/{ARCH}/{CATEGORY}/{Package Name}/app.json` 的 `DownloadTimes` + +`DownloadTimes` 为正整数(下载次数),字段缺失时客户端视为「未知」,不展示下载量。 +下文 `download-times.txt` 接口仍然保留可用,但客户端已不再使用。 + +--- + +## 1. 获取下载次数(旧接口,客户端已不再请求) 获取指定应用的下载次数