Files
UHD_Media_Tool/apps/server/src/modules/pi/routes.ts
T
rootandClaude Opus 5.5 2b8fe667a5 fix(organize,identity,sites,pi): W10-pre backend fixes from v0.2 visual QA
- slot: new manual_verify event (attention -> verifying, with reason); a
  successful organize of a download whose slot is in attention goes back
  through verifying and lands by verify_passed on the actually imported
  assets; the download's organize_blocked decision is resolved with
  resolution {key: manual_organize | organize_retry, organizationRecordId} (U1)
- acquisition: plan purpose is upgrade only when the slot already has a
  current asset; a movie slot gap is a fill (U7)
- identity: entities created from TMDB details get metadata fields projected
  in the same transaction; TMDB aliases deduped by normalized text, site-title
  aliases skipped when the text already exists (U9)
- pi: auth-mode test of a custom Base URL no longer marks the upstream ok, and
  says to use the minimal completion to probe the endpoint (U2)
- sites: PUT /sites/{id}/credentials replaces same-kind secrets in place and
  names new secrets with site id + random suffix (secret.name is unique; was a
  500 on update); M-Team user data uses config/definition apiBaseUrl instead
  of deriving api.<domain> from the site URL (Invalid URL on IP/local bases)
- copy: setup error says 初始化口令; backup job points at umt backup
- tests: schema expected tables add site_user_data; migration round-trip
  timeout 60s; fake secrets honor label as name; vertical/resolve/pi/slot/
  site-center tests for the above
- docs: 09-visual-qa W10-pre status table (U3 root cause: outbox events are
  never mirrored to SSE)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:29:29 +08:00

164 lines
7.1 KiB
TypeScript

import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { providerCatalog, type UpstreamClientOptions, UpstreamError } from "@umt/agent";
import {
type Actor,
Api,
apiResponse,
type EndpointDef,
type PiOAuthCompleteRequest,
type PiOAuthStartRequest,
type PiUpstreamCreate,
type PiUpstreamTestRequest,
type PiUpstreamUpdate,
} from "@umt/contracts";
import type { z } from "zod";
import { requireAdminSession, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import type { OAuthSessions } from "./oauth.ts";
import { requireRow } from "./oauth.ts";
import { refreshUpstream, resolveExpiredDecision } from "./refresh.ts";
import { auditPi, clientFor, createUpstream, deleteUpstream, listUpstreams, updateUpstream } from "./service.ts";
import { getRow, setStatus, toView } from "./store.ts";
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
interface Input<D extends EndpointDef> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
actor: Actor | undefined;
}
/** 薄封装:合同 EndpointDef → OpenAPI 路由 + requireScope(+ session)+ 出参按合同 parse */
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (i: Input<D>) => Promise<unknown>) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const r = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: ["pi"],
middleware: [requireScope(def.scope), ...(def.auth === "session" ? [requireAdminSession()] : [])] as never,
request,
responses: {
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
actor: c.get("actor"),
} as Input<D>;
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, 200);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
export function registerPiRoutes(
app: ModuleApp,
ctx: ServerContext,
deps: { oauth: () => OAuthSessions; client?: UpstreamClientOptions },
) {
route(app, Api.pi.providers, async ({ query }) => providerCatalog({ models: query.models !== "false" }));
route(app, Api.pi.list, async () => await listUpstreams(ctx));
route(app, Api.pi.get, async ({ params }) => await toView(ctx, await requireRow(ctx, params.id)));
route(app, Api.pi.create, async ({ body, actor }) => await createUpstream(ctx, actor, body as PiUpstreamCreate));
route(app, Api.pi.update, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
return await updateUpstream(ctx, actor, row, body as PiUpstreamUpdate, () => deps.oauth().cancelUpstream(row.id));
});
route(app, Api.pi.remove, async ({ params, actor }) => {
const row = await requireRow(ctx, params.id);
deps.oauth().cancelUpstream(row.id);
await deleteUpstream(ctx, actor, row);
return { ok: true as const };
});
route(app, Api.pi.test, async ({ params, body }) => {
const row = await requireRow(ctx, params.id);
const req = body as PiUpstreamTestRequest;
const mode = req.mode ?? "auth";
const started = Date.now();
let out: {
ok: boolean;
source: string | null;
model: string | null;
errorKind: UpstreamError["kind"] | null;
message: string | null;
};
try {
// OAuth 型先走与定时任务同一条刷新路径(失败会标 expired 并建待处理事项)
if (row.auth_kind === "oauth" && row.secret_ref) {
const r = await refreshUpstream(ctx, row, { windowMs: 0, ...(deps.client ? { client: deps.client } : {}) });
if (!r.ok) throw new UpstreamError(r.kind as UpstreamError["kind"], r.message);
}
const t = await clientFor(ctx, row, deps.client).test({ mode, ...(req.model ? { model: req.model } : {}) });
// 自定义 Base URL 的凭证检查不连端点:不据此改写可用状态,提示用最小补全确认可达(QA U2)
const unprobed = mode === "auth" && !!row.base_url && row.auth_kind !== "oauth";
const notes = [
t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
unprobed ? "只核对了凭证与模型,没有连接 Base URL;用「最小补全」确认端点可达" : null,
].filter(Boolean);
out = { ok: true, source: t.source, model: t.model, errorKind: null, message: notes.join(";") || null };
if (!unprobed) await setStatus(ctx, row.id, { status: "ok", verified: true });
} catch (err) {
const e = err instanceof UpstreamError ? err : new UpstreamError("other", (err as Error).message);
out = { ok: false, source: null, model: req.model ?? row.default_model, errorKind: e.kind, message: e.message };
const fresh = await getRow(ctx, row.id);
if (fresh && fresh.status !== "expired")
await setStatus(ctx, row.id, {
status: e.kind === "auth" ? "auth_required" : "error",
lastError: e.message,
});
}
return { ...out, mode, latencyMs: Date.now() - started, upstream: await toView(ctx, (await getRow(ctx, row.id))!) };
});
route(app, Api.pi.models, async ({ params }) => {
const row = await requireRow(ctx, params.id);
try {
return await clientFor(ctx, row, deps.client).listModels();
} catch (err) {
if (err instanceof UpstreamError) throw new ApiException("validation_failed", err.message);
throw err;
}
});
route(app, Api.pi.oauthStart, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
const r = await deps.oauth().start(row, body as PiOAuthStartRequest);
await auditPi(ctx, actor, "pi_upstream.oauth_start", row.id, { mode: r.mode, sessionId: r.sessionId });
return r;
});
route(app, Api.pi.oauthComplete, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
const req = body as PiOAuthCompleteRequest;
const r = await deps.oauth().complete(row.id, req);
if (r.state === "succeeded") {
await resolveExpiredDecision(ctx, row.id);
await auditPi(ctx, actor, "pi_upstream.oauth_login", row.id, { sessionId: r.sessionId });
}
return { ...r, upstream: await toView(ctx, (await getRow(ctx, row.id))!) };
});
route(app, Api.pi.oauthLogout, async ({ params, actor }) => {
const row = await requireRow(ctx, params.id);
deps.oauth().cancelUpstream(row.id);
await clientFor(ctx, row, deps.client).logout();
await auditPi(ctx, actor, "pi_upstream.oauth_logout", row.id);
return await toView(ctx, (await getRow(ctx, row.id))!);
});
}