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

@@ -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 }) => {