From 9275f55cc6942e6363bdab343b0909d7a0468236 Mon Sep 17 00:00:00 2001 From: std-external Date: Sun, 1 Nov 2026 12:56:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(catalog):=20=E7=9B=AE=E5=BD=95=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E7=BC=BA=E5=AD=97=E6=AE=B5=E6=97=B6=E6=8A=A5=E9=94=99?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E5=8F=98=E6=88=90?= =?UTF-8?q?=E7=A9=BA=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游把返回体的字段改名或漏掉时,前端原来用「不是数组就当空数组」兜底, 结果和「目录里真的没有 Mod」长得一模一样:列表空、没有错误、日志里也 什么都没有(今天镜像只剩 1 条时就是这个观感)。 现在把返回体解析抽成 parseCatalogResponse:data 不是数组、code 不是 200、 或根本不是 JSON 时直接抛错。界面上会走现有的「加载失败,请重试」,错误 也会经前端日志落盘,能看出是哪一侧出了问题,不用再靠猜。 --- src/celemod-ui/package.json | 1 + .../src/api/catalogResponse.test.ts | 50 +++++++++++++++++++ src/celemod-ui/src/api/catalogResponse.ts | 34 +++++++++++++ src/celemod-ui/src/api/modCatalog.ts | 10 +--- 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 src/celemod-ui/src/api/catalogResponse.test.ts create mode 100644 src/celemod-ui/src/api/catalogResponse.ts diff --git a/src/celemod-ui/package.json b/src/celemod-ui/package.json index cbed3c0..05ae27d 100644 --- a/src/celemod-ui/package.json +++ b/src/celemod-ui/package.json @@ -16,6 +16,7 @@ "test:catalog-download": "node --import tsx --test src/catalogDownload.test.ts", "test:crash-trace": "node --import tsx --test src/components/crashTrace.test.ts", "test:deep-link": "node --import tsx --test src/deepLink.test.ts", + "test:catalog-response": "node --import tsx --test src/api/catalogResponse.test.ts", "test:mod-updates": "node --import tsx --test src/modUpdates.test.ts", "test:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx" }, diff --git a/src/celemod-ui/src/api/catalogResponse.test.ts b/src/celemod-ui/src/api/catalogResponse.test.ts new file mode 100644 index 0000000..90ca943 --- /dev/null +++ b/src/celemod-ui/src/api/catalogResponse.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseCatalogResponse } from "./catalogResponse"; + +test("a normal catalog response yields its Mods", () => { + const mods = parseCatalogResponse<{ name: string }>( + '{"data":[{"name":"SpeedrunTool"}],"code":200,"message":"OK"}', + ); + assert.deepEqual(mods, [{ name: "SpeedrunTool" }]); +}); + +test("an empty catalog is still a valid answer", () => { + assert.deepEqual( + parseCatalogResponse('{"data":[],"code":200,"message":"OK"}'), + [], + ); +}); + +/** + * A mirror that renames or drops `data` used to look like an empty catalog: + * no Mods, no error, nothing in the log. + */ +test("a response without a Mod list is an error instead of an empty catalog", () => { + assert.throws( + () => parseCatalogResponse('{"mods":[],"code":200,"message":"OK"}'), + /no Mod list/, + ); + assert.throws( + () => parseCatalogResponse('{"data":null,"code":200,"message":"OK"}'), + /no Mod list/, + ); +}); + +test("a failed catalog request keeps its reason", () => { + assert.throws( + () => + parseCatalogResponse( + '{"data":null,"code":404,"message":"接口未找到"}', + ), + /code 404/, + ); + assert.throws( + () => parseCatalogResponse('{"data":[],"code":500,"message":"boom"}'), + /code 500.*boom/, + ); +}); + +test("a non-JSON body is an error", () => { + assert.throws(() => parseCatalogResponse("502"), /not valid JSON/); +}); diff --git a/src/celemod-ui/src/api/catalogResponse.ts b/src/celemod-ui/src/api/catalogResponse.ts new file mode 100644 index 0000000..5f3d325 --- /dev/null +++ b/src/celemod-ui/src/api/catalogResponse.ts @@ -0,0 +1,34 @@ +/** + * Parsing of the raw Mod catalog response. + * + * The catalog comes from the mirror (`/api/v2/mod/list`), and the UI used to + * treat anything that was not an array as an empty catalog. A mirror that + * renames or drops a top-level field therefore looked exactly like a mirror + * with no Mods at all: an empty list, no error, and nothing in the log. The + * response shape is checked here so that case fails loudly instead. + */ +export interface CatalogResponse { + data: T[]; + code: number; + message: string; +} + +export const parseCatalogResponse = (raw: string): T[] => { + let parsed: Partial>; + try { + parsed = JSON.parse(raw) as Partial>; + } catch (error) { + throw new Error(`Mod catalog response is not valid JSON: ${String(error)}`); + } + if (!Array.isArray(parsed?.data)) { + throw new Error( + `Mod catalog response has no Mod list (code ${parsed?.code ?? "?"}, message ${parsed?.message ?? ""})`, + ); + } + if (typeof parsed.code === "number" && parsed.code !== 200) { + throw new Error( + `Mod catalog request failed (code ${parsed.code}, message ${parsed.message ?? ""})`, + ); + } + return parsed.data; +}; diff --git a/src/celemod-ui/src/api/modCatalog.ts b/src/celemod-ui/src/api/modCatalog.ts index 1b87feb..7782868 100644 --- a/src/celemod-ui/src/api/modCatalog.ts +++ b/src/celemod-ui/src/api/modCatalog.ts @@ -1,6 +1,7 @@ import type { Content, File as SubmissionFile } from "./wegfan"; import { callRemote, sleep } from "../utils"; import { waitForCatalogDownload } from "../catalogDownload"; +import { parseCatalogResponse } from "./catalogResponse"; export interface CatalogSubmission { id: string; @@ -44,12 +45,6 @@ export interface CatalogMod { submissionFile: CatalogSubmissionFile; } -interface CatalogResponse { - data: CatalogMod[]; - code: number; - message: string; -} - export interface LocalCatalogFilters { search: string; category: string; @@ -98,8 +93,7 @@ export const isCatalogDownloading = async (): Promise => { const requestModCatalog = (requestId: number, forceRefresh: boolean) => callRemote("get_mod_catalog", forceRefresh).then((raw) => { - const parsed = JSON.parse(raw) as CatalogResponse; - const nextCatalog = Array.isArray(parsed.data) ? parsed.data : []; + const nextCatalog = parseCatalogResponse(raw); if (requestId === catalogRequestId) catalog = nextCatalog; return nextCatalog; });