diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index 220019f7..5d4d2769 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -79,13 +79,12 @@ export class ReleaseFanout { /** * 在给定站点上搜一组查询词:每站每词一次请求(同站多个词的结果按站点资源 ID 去重)。 - * 缓存按(站点, 查询词):失败 / 超时也缓存,但一轮里全部站点都失败时不缓存(下次直接重试)。 + * 缓存按(站点, 查询词):只缓存成功的结果;失败 / 超时 / 验证拦截 / 跳过不写缓存,下次搜索直接重试。 */ async search(terms: string[], sites: SiteRow[], opts: { refresh?: boolean } = {}): Promise { const words = [...new Map(terms.map((t) => [cacheKey(t), t.trim()])).entries()].filter(([k]) => k); let cached = true; let coalesced = false; - const fresh: string[] = []; const perSite = await mapLimit(sites, Math.max(1, this.opts.concurrency), async (site) => { const parts: SiteResult[] = []; for (const [k, term] of words) { @@ -105,16 +104,11 @@ export class ReleaseFanout { const p = this.searchSite(this.rt(), site, term).finally(() => this.inflight.delete(key)); this.inflight.set(key, p); const r = await p; - this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, result: r }); - fresh.push(key); + if (r.coverage.status === "ok") this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, result: r }); parts.push(r); } return mergeSite(site, parts); }); - // 本轮新查的站点全部失败:撤掉这些缓存 - const freshResults = fresh.map((k) => this.cache.get(k)?.result).filter((r): r is SiteResult => !!r); - if (freshResults.length && freshResults.every((r) => r.coverage.status !== "ok")) - for (const k of fresh) this.cache.delete(k); this.prune(); const fetched = perSite.map((r) => r.fetchedAt); return { diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index 45c449ef..efadc6af 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -289,13 +289,20 @@ describe.skipIf(!up)("全局搜索", () => { expect(rows.length).toBe(3); }); - it("30s 内相同查询命中缓存,不再请求站点", async () => { + it("30s 内相同查询:成功的站点命中缓存不再请求;超时的站点不缓存,每次重试", async () => { const before = [nexusSearches(), mteamSearches(), slowCalls]; const r = await req("/search?q=%20dune%20&scope=releases"); - expect(r.body.data.releases).toMatchObject({ cached: true, total: 3, partial: true }); + // 超时的站点重新查了一次,所以整体不算缓存结果 + expect(r.body.data.releases).toMatchObject({ cached: false, total: 3, partial: true }); + expect([nexusSearches(), mteamSearches()]).toEqual(before.slice(0, 2)); + expect(slowCalls).toBe(before[2]! + 1); const page = await req("/search/releases?q=Dune"); - expect(page.body.data).toMatchObject({ cached: true, total: 3 }); - expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before); + expect(page.body.data).toMatchObject({ cached: false, total: 3, partial: true }); + expect([nexusSearches(), mteamSearches()]).toEqual(before.slice(0, 2)); + expect(slowCalls).toBe(before[2]! + 2); + // 只查成功过的站点:全部命中缓存 + const okOnly = await req(`/search/releases?q=Dune&sites=${sites.nexus},${sites.mteam}`); + expect(okOnly.body.data).toMatchObject({ cached: true, total: 3, partial: false }); // refresh=true 跳过缓存 const fresh = await req("/search/releases?q=Dune&refresh=true"); expect(fresh.body.data.cached).toBe(false); @@ -387,7 +394,9 @@ describe.skipIf(!up)("全局搜索", () => { }); it("筛选 / 排序 / 游标 / 分面", async () => { - const q = (s: string) => req(`/search/releases?q=Dune&${s}`).then((r) => r.body.data); + // 只查成功的两站(超时站点不缓存,每次重试会拖慢用例) + const ok = `sites=${sites.nexus},${sites.mteam}`; + const q = (s: string) => req(`/search/releases?q=Dune&${ok}&${s}`).then((r) => r.body.data); let d = await q("resolution=2160p"); expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["812345", "990001"]); expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 }); @@ -399,7 +408,7 @@ describe.skipIf(!up)("全局搜索", () => { d = await q("source=web-dl,REMUX&free=false"); expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["900001", "990001"]); - d = await q(`sites=${sites.mteam}`); + d = (await req(`/search/releases?q=Dune&sites=${sites.mteam}`)).body.data; expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]); d = await q(`minSize=${10 * 1024 ** 3}&maxSize=${50 * 1024 ** 3}`); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]); @@ -420,8 +429,9 @@ describe.skipIf(!up)("全局搜索", () => { }); it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => { + const ok = `sites=${sites.nexus},${sites.mteam}`; const q = (qf: string, extra = "") => - req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data); + req(`/search/releases?q=Dune&${ok}&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data); const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count])); const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort();