feat(search): global search aggregation backend (W9-G)

- GET /search?q=&scope=all|media|releases|actions&limit=: local works (title_alias
  exact/prefix/trgm + name prefix, slot status summary), TMDB candidates (cached,
  localEntityId), cross-site releases, page/action jump list; tookMs per group
- cross-site: enabled sites via shared per-site limiter, 15s AbortSignal timeout,
  site concurrency cap 4, same-query coalescing, 30s cache; partial + failedSites;
  results upserted to source_release; inventory overlay via GET /inventory
- GET /search/releases: site/resolution/source/size/free/H&R filters, sort, cursor,
  facets; shares the cache
- POST /search/releases/{siteId}/{siteReleaseId}/plan: forwards in-process to
  POST /acquisition-plans (same auth, validation, audit, rejections)
- contracts/search.ts, runbook, tests (partial/timeout, coalesce, cache, overlay,
  filters/sort, plan)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:05:58 +08:00
1 parent e0c0c4f51d
commit a0946bf480
12 files changed
+1869 -1

No files matched your search

@@ -0,0 +1,75 @@
import type { Context } from "hono";
import { Hono } from "hono";
import type { AuthEnv } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp } from "../types.ts";
// 进程内转调其它模块的 HTTP 端点(库存叠加 GET /inventory、下载 POST /acquisition-plans):
// 走对方真实的路由(scope 校验 / 入参校验 / 审计 / 业务逻辑都不复制),调用者身份沿用本次请求已通过鉴权的 principal。
// 所有模块路由注册完后第一次调用时才组装(route() 按调用时刻复制路由表)。
interface Origin {
principal: AuthEnv["Variables"]["principal"];
actor: AuthEnv["Variables"]["actor"];
session: AuthEnv["Variables"]["session"];
requestId: string | undefined;
}
export class InternalDispatcher {
private inner: Hono<AuthEnv> | null = null;
private readonly origins = new WeakMap<Request, Origin>();
private readonly errors = new WeakMap<Request, unknown>();
constructor(private readonly api: ModuleApp) {}
private app(): Hono<AuthEnv> {
if (this.inner) return this.inner;
const inner = new Hono<AuthEnv>();
inner.use("*", async (c, next) => {
const o = this.origins.get(c.req.raw);
if (!o) throw new ApiException("unauthorized", "内部调用缺少调用者");
c.set("principal", o.principal);
c.set("actor", o.actor);
c.set("session", o.session);
if (o.requestId) c.set("requestId" as never, o.requestId as never);
await next();
});
inner.route("/", this.api);
// 原样抛回原始错误(ApiException 由外层 app.onError 转信封)
inner.onError((err, c) => {
this.errors.set(c.req.raw, err);
return c.body(null, 500);
});
inner.notFound((c) => {
this.errors.set(c.req.raw, new ApiException("internal", `内部端点不存在:${c.req.method} ${c.req.path}`));
return c.body(null, 404);
});
this.inner = inner;
return inner;
}
async call<T>(from: Context<AuthEnv>, method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const req = new Request(`http://internal${path}`, {
method,
signal: from.req.raw.signal,
...(body !== undefined ? { headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {}),
});
this.origins.set(req, {
principal: from.get("principal"),
actor: from.get("actor"),
session: from.get("session"),
requestId: from.get("requestId" as never) as string | undefined,
});
const res = await this.app().fetch(req);
const err = this.errors.get(req);
if (err) throw err;
const json = (await res.json()) as { ok: boolean; data?: T; error?: { code: string; message: string } };
if (!json.ok) {
throw new ApiException(
(json.error?.code ?? "internal") as never,
json.error?.message ?? `内部调用失败(${res.status})`,
);
}
return json.data as T;
}
}