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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user