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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user