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