feat(server): W4-A integration endpoints and contract gaps

- POST /acquisition-plans: manual submit via download.plan -> download.submit
  (same authorization/audit/idempotency path as the agent), slotId or entityId
- read-only /media-assets, /asset-locations, /asset-unit-coverage, /path-bindings,
  /reconcile-runs, /server-mappings, /artifacts, GET /title-aliases/{id};
  unimplemented write endpoints removed from api.ts; registry test now checks both directions
- candidate evaluation upsert per (slot, source_release) + migration 0022 dedupe/unique index
- organization records expose downloadTaskId (+ list filter); inventory adds
  subscribed/candidateReasons; feed sources expose derived possibleGap
- GET /events?after=&limit= JSON replay page for API key callers (same sse_event table)
- preference tag revision history from audit_event; subscriptions/identities/aliases
  lists carry mediaEntityTitle; subscription q/mediaKind search; slot subscriptionId
  filter + currentAsset summary; policy preview samples carry title/reasons
- DecisionSummary, IdentityRevisionList, ReleaseIdentityResult, PolicyPreviewSummary exported from contracts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 21:10:02 +08:00
1 parent ea4c384664
commit d3ab54f2bd
34 files changed
+1083 -123

No files matched your search

@@ -243,6 +243,7 @@ export const D = {
{ mediaEntityId: Id.optional(), q: z.string().max(200).optional() },
TI,
),
get: get("/title-aliases/{id}", "别名详情", TitleAlias, TI),
create: write("post", "/title-aliases", "organize.execute", "新增别名", TitleAliasCreate, TitleAlias, TI, false),
remove: write("delete", "/title-aliases/{id}", "organize.execute", "删除别名", undefined, OkResult, TI),
},
@@ -17,7 +17,7 @@ import {
ReleaseIdentityAssign,
type TitleAlias,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { type DB, type Kysely, sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { z } from "zod";
import { ApiException } from "../../../http/errors.ts";
@@ -44,6 +44,30 @@ const T_ID = ["identity"];
const Accepted = z.object({ taskId: Id });
const FieldParam = z.object({ id: Id, field: EntityMetadataField });
/** 列表带作品标题:按 media_entity_id 批量取;只挂在内容单元上的行以 `cu:<id>` 为键取所属作品标题 */
async function entityTitles(
db: Kysely<DB>,
rows: readonly { media_entity_id: string | null; content_unit_id?: string | null }[],
): Promise<Map<string, string>> {
const out = new Map<string, string>();
const ids = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))];
const cus = [...new Set(rows.filter((r) => !r.media_entity_id && r.content_unit_id).map((r) => r.content_unit_id!))];
if (ids.length) {
for (const e of await db.selectFrom("media_entity").select(["id", "title"]).where("id", "in", ids).execute())
out.set(e.id, e.title);
}
if (cus.length) {
const units = await db
.selectFrom("content_unit as u")
.innerJoin("media_entity as e", "e.id", "u.media_entity_id")
.select(["u.id", "e.title"])
.where("u.id", "in", cus)
.execute();
for (const u of units) out.set(`cu:${u.id}`, u.title);
}
return out;
}
export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const db = () => rt().ctx.db;
@@ -329,7 +353,16 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.limit(query.limit + 1)
.execute();
const p = page(rows, off, query.limit);
return { items: toApiList<TitleAlias>(p.items), nextCursor: p.nextCursor };
const titles = await entityTitles(db(), p.items);
return {
items: p.items.map((r) => ({ ...toApi<TitleAlias>(r), mediaEntityTitle: titles.get(r.media_entity_id) ?? null })),
nextCursor: p.nextCursor,
};
});
route(app, D.titleAliases.get, async ({ params }) => {
const row = await db().selectFrom("title_alias").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!row) throw new ApiException("not_found", "别名不存在");
return toApi<TitleAlias>(row);
});
route(app, D.titleAliases.create, async ({ body, actor }) => {
const id = await resolveEntityId(db(), body.mediaEntityId);
@@ -379,7 +412,14 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.limit(query.limit + 1)
.execute();
const p = page(rows, off, query.limit);
return { items: toApiList<ExternalIdentity>(p.items), nextCursor: p.nextCursor };
const titles = await entityTitles(db(), p.items);
return {
items: p.items.map((r) => ({
...toApi<ExternalIdentity>(r),
mediaEntityTitle: titles.get(r.media_entity_id ?? `cu:${r.content_unit_id}`) ?? null,
})),
nextCursor: p.nextCursor,
};
});
route(app, D.externalIdentities.get, async ({ params }) => {
const row = await db().selectFrom("external_identity").selectAll().where("id", "=", params.id).executeTakeFirst();
@@ -22,7 +22,7 @@ import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { D } from "../defs.ts";
import { route } from "../http.ts";
import { decodeCursor, jsonb, page, toApi, toApiList } from "../rows.ts";
import { decodeCursor, feedToApi, jsonb, page, toApi, toApiList } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { audit, closeDecisions } from "../service/common.ts";
import { assignReleaseIdentity } from "../service/identity-admin.ts";
@@ -328,9 +328,9 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.limit(query.limit + 1)
.execute();
const p = page(rows, off, query.limit);
return { items: toApiList<FeedSource>(p.items), nextCursor: p.nextCursor };
return { items: p.items.map((r) => feedToApi<FeedSource>(r)), nextCursor: p.nextCursor };
});
route(app, D.feedSources.get, async ({ params }) => toApi(await getFeed(params.id)));
route(app, D.feedSources.get, async ({ params }) => feedToApi(await getFeed(params.id)));
route(app, D.feedSources.create, async ({ body, actor }) => {
if (body.siteId) await getSite(body.siteId);
if (body.siteAccountId) await checkAccount(body.siteAccountId, body.siteId ?? null);
@@ -352,7 +352,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.returningAll()
.executeTakeFirstOrThrow();
await audit(db(), actor, "feed_source.created", { type: "feed_source", id: row.id }, { url: row.url_display });
return toApi(row);
return feedToApi(row);
});
route(app, D.feedSources.update, async ({ params, body, actor }) => {
const feed = await getFeed(params.id);
@@ -402,7 +402,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
fields: Object.keys(patch).filter((k) => k !== "url_secret_ref"),
},
);
return toApi(row);
return feedToApi(row);
});
for (const enabled of [true, false]) {
route(
@@ -432,7 +432,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
type: "feed_source",
id: params.id,
});
return toApi(row);
return feedToApi(row);
},
);
}
@@ -485,7 +485,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
await audit(trx, actor, "feed_source.reset", { type: "feed_source", id: params.id });
return r;
});
return toApi(row);
return feedToApi(row);
},
);
route(app, D.feedSources.fetchNow, async ({ params }) => {
@@ -35,3 +35,18 @@ export function page<T>(rows: T[], offset: number, limit: number): { items: T[];
/** jsonb 写入:数组/对象都先 JSON.stringify(pg 会把 JS 数组当 PG 数组) */
export const jsonb = (v: unknown): string => JSON.stringify(v ?? null);
/** feed_source 行 → FeedSource(补派生字段 possibleGap,取自 cursor.gap) */
export function feedToApi<T>(row: { cursor: unknown }): T {
const gap = (row.cursor as { gap?: { detectedAt?: unknown; previousFetchedAt?: unknown; backfillQueued?: unknown } })
?.gap;
const possibleGap =
gap && typeof gap.detectedAt === "string"
? {
detectedAt: gap.detectedAt,
previousFetchedAt: typeof gap.previousFetchedAt === "string" ? gap.previousFetchedAt : null,
backfillQueued: gap.backfillQueued === true,
}
: null;
return { ...toApi<Record<string, unknown>>(row), possibleGap } as T;
}
@@ -17,9 +17,11 @@ import { clip, type Exec, openDecision } from "./common.ts";
// FeedFetchState / FeedCursor 持久化在 feed_source 行;enclosure 只存脱敏值(适配器已脱敏)。
/** feed_source.cursor 的形状:重叠窗口游标 + 熔断打开时间 */
interface StoredCursor {
export interface StoredCursor {
window?: FeedCursor;
circuitOpenedAt?: string;
/** 最近一次疑似缺口(窗口零重叠);下一次成功抓取重新衔接后清空 */
gap?: { detectedAt: string; previousFetchedAt: string | null; backfillQueued: boolean };
}
const AUTH_BACKOFF_MS = 24 * 3600_000;
@@ -144,9 +146,21 @@ export async function ingestFeed(
const lastError = auth
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" }
: (state.lastError ?? null);
// 疑似缺口:本次 ok 且零重叠 → 记下;本次 ok 且衔接上 → 清空;抓取失败 → 保留上次状态
const gap: StoredCursor["gap"] =
result.kind === "ok"
? out.possibleGap
? {
detectedAt: now.toISOString(),
previousFetchedAt: cursor.window?.lastFetchedAt ?? null,
backfillQueued: !!site?.id,
}
: undefined
: cursor.gap;
const nextCursor: StoredCursor = {
window,
...(state.circuit === "open" && state.circuitOpenedAt ? { circuitOpenedAt: state.circuitOpenedAt } : {}),
...(gap ? { gap } : {}),
};
await trx
.updateTable("feed_source")