feat(server): W2-B sources, identity resolution, metadata and site workbench
- Sites / accounts / feeds: registry view (/site-definitions), site CRUD with
enable/disable (history stays queryable, marked site_disabled), accounts and
feed URLs stored as secrets (only secret_ref / redacted urlDisplay exposed),
feed enable/disable/reset/fetch; one shared rate limiter + adapter per site.
- Tasks: ingestFeed (FeedFetchState/FeedCursor on feed_source, observation
feed+guid upsert with digest-change updates, source_release site+id
aggregation across feeds, auth failure -> account invalid + decision, no
retry, gap -> feed.gap_suspected -> budgeted backfill snapshot),
resolveObservation (parseRelease -> local recall via title_alias pg_trgm +
accepted identities -> TMDB -> resolveIdentity; auto binds entity /
provisional units / identities / aliases, otherwise identity_candidate pool
with evidence version and bounded review backoff; manual lock respected),
refreshMetadata (metadata_field + field_override, user locks win, paths
untouched), snapshotSite (site_snapshot coverage records).
- Events: feed.due, feed.gap_suspected, release.observed (self-consumed),
release.resolved, decision.created/updated; scheduler in worker start().
- Identity API: entity detail with redirects, manual mapping correction with
identity_revision, merge with identity_redirect, pool list/assign/dismiss/
retry, content unit numbering lock, aliases, external identities.
- Decisions: list/summary (by entity and kind)/detail/resolve; identity
options executed directly.
- Workbench: /sites/{id}/releases (snapshot first, coalesced live search with
backoff, origin + freshness) and /sites/{id}/releases/{rid} (volatile field
recheck times, refresh keeps data on failure).
- Capabilities: catalog.lookup, release.search, release.inspect,
identity.resolve.
- Migrations 0018-0020: site.adapter api, source_release.resolution_locked,
site_snapshot, identity_candidate, resolution_attempt, identity_redirect,
metadata_field, field_override.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
829ca4f7ac
commit
a24bda952b
39 files changed
+6861
-15
No files matched your search
@@ -0,0 +1,184 @@
|
||||
import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
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 type { SourcesRuntime } from "../runtime.ts";
|
||||
import { audit } from "../service/common.ts";
|
||||
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
|
||||
|
||||
// 待处理中心数据源:decision 列表/详情/聚合/处理。
|
||||
// 身份歧义类由本模块直接执行所选候选;其它类型只记录处理结果并发 decision.updated,
|
||||
// 需要执行能力的选项由 W2-D 的 resolveDecision 消费。
|
||||
|
||||
const OPEN = ["open", "investigating", "awaiting_user"] as const;
|
||||
const T = ["decisions"];
|
||||
|
||||
const SummaryGroup = z.object({
|
||||
kind: DecisionKind,
|
||||
/** 关联作品(身份歧义等尚未归属作品的为 null) */
|
||||
mediaEntityId: Id.nullable(),
|
||||
mediaEntityTitle: z.string().nullable(),
|
||||
count: z.number().int(),
|
||||
maxSeverity: z.enum(["info", "warning", "critical"]),
|
||||
latestAt: z.string(),
|
||||
});
|
||||
|
||||
export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const db = () => rt().ctx.db;
|
||||
|
||||
route(app, D.decisions.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("decision").selectAll();
|
||||
q = query.status ? q.where("status", "=", query.status) : q.where("status", "in", [...OPEN]);
|
||||
if (query.kind) q = q.where("kind", "=", query.kind);
|
||||
const rows = await q
|
||||
.orderBy(sql`case severity when 'critical' then 0 when 'warning' then 1 else 2 end`)
|
||||
.orderBy("created_at", "desc")
|
||||
.orderBy("id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<Decision>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/decisions/summary",
|
||||
scope: "read",
|
||||
summary: "未关闭待处理事项按作品与问题类型聚合",
|
||||
tags: T,
|
||||
query: z.object({ status: DecisionStatus.optional() }),
|
||||
response: z.object({ total: z.number().int(), groups: z.array(SummaryGroup) }),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const statuses = query.status ? [query.status] : [...OPEN];
|
||||
// 作品归属:evidence.mediaEntityId,或 subject 为 media_entity,或 subject 为已识别的 source_release
|
||||
const rows = await sql<{
|
||||
kind: z.infer<typeof DecisionKind>;
|
||||
entity_id: string | null;
|
||||
entity_title: string | null;
|
||||
count: number;
|
||||
max_sev: number;
|
||||
latest: Date;
|
||||
}>`
|
||||
with d as (
|
||||
select d.*, coalesce(
|
||||
case when d.evidence->>'mediaEntityId' ~ '^[0-9a-f-]{36}$' then (d.evidence->>'mediaEntityId')::uuid end,
|
||||
case when d.subject_type = 'media_entity' then d.subject_id end,
|
||||
(select r.media_entity_id from source_release r where d.subject_type = 'source_release' and r.id = d.subject_id)
|
||||
) as entity_id
|
||||
from decision d where d.status = any(${statuses})
|
||||
)
|
||||
select d.kind, d.entity_id, e.title as entity_title, count(*)::int as count,
|
||||
max(case d.severity when 'critical' then 2 when 'warning' then 1 else 0 end)::int as max_sev,
|
||||
max(d.created_at) as latest
|
||||
from d left join media_entity e on e.id = d.entity_id
|
||||
group by d.kind, d.entity_id, e.title
|
||||
order by max_sev desc, latest desc`.execute(db());
|
||||
const sev = ["info", "warning", "critical"] as const;
|
||||
return {
|
||||
total: rows.rows.reduce((a, r) => a + r.count, 0),
|
||||
groups: rows.rows.map((r) => ({
|
||||
kind: r.kind,
|
||||
mediaEntityId: r.entity_id,
|
||||
mediaEntityTitle: r.entity_title,
|
||||
count: r.count,
|
||||
maxSeverity: sev[r.max_sev] ?? "info",
|
||||
latestAt: new Date(r.latest).toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
route(app, D.decisions.get, async ({ params }) => {
|
||||
const row = await db().selectFrom("decision").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "待处理事项不存在");
|
||||
return toApi(row);
|
||||
});
|
||||
|
||||
route(app, D.decisions.resolve, async ({ params, body, actor }) => {
|
||||
const d = await db().selectFrom("decision").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!d) throw new ApiException("not_found", "待处理事项不存在");
|
||||
if (!(OPEN as readonly string[]).includes(d.status)) throw new ApiException("conflict", `事项已是 ${d.status}`);
|
||||
if (!body.dismiss && !body.optionKey) throw new ApiException("bad_request", "需要 optionKey 或 dismiss");
|
||||
const by = `${actor.type}:${actor.id ?? ""}`;
|
||||
const options = (d.options ?? []) as { key: string; input?: Record<string, unknown> }[];
|
||||
const opt = body.optionKey ? options.find((o) => o.key === body.optionKey) : undefined;
|
||||
if (body.optionKey && !opt) throw new ApiException("bad_request", `未知选项 ${body.optionKey}`);
|
||||
|
||||
// 身份歧义:直接执行(选候选 = 人工指定并加锁;忽略 = 待识别池 dismissed)
|
||||
if (d.kind === "identity_ambiguous" && d.subject_type === "source_release" && d.subject_id) {
|
||||
if (body.dismiss || opt?.key === "dismiss") {
|
||||
const pool = await db()
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["id"])
|
||||
.where("source_release_id", "=", d.subject_id)
|
||||
.executeTakeFirst();
|
||||
if (pool) await dismissPoolEntry(rt(), pool.id, actor, body.note ?? null);
|
||||
} else if (opt?.input && typeof opt.input.candidateIndex === "number") {
|
||||
const pool = await db()
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["candidates"])
|
||||
.where("source_release_id", "=", d.subject_id)
|
||||
.executeTakeFirst();
|
||||
if (!(pool?.candidates as PoolCandidate[] | undefined)?.[opt.input.candidateIndex]) {
|
||||
throw new ApiException("conflict", "候选已变化,请刷新后重试");
|
||||
}
|
||||
await assignReleaseIdentity(
|
||||
rt(),
|
||||
d.subject_id,
|
||||
{ candidateIndex: opt.input.candidateIndex, lock: true, reason: body.note ?? `decision ${d.id}` },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 上面的执行路径已按 dedupeKey 关闭;其余类型在这里记录处理结果
|
||||
const row = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const cur = await trx
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("id", "=", params.id)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (!(OPEN as readonly string[]).includes(cur.status)) return cur;
|
||||
const status = body.dismiss || opt?.key === "dismiss" ? "dismissed" : "resolved";
|
||||
const r = await trx
|
||||
.updateTable("decision")
|
||||
.set({
|
||||
status,
|
||||
resolution: jsonb({ optionKey: body.optionKey ?? null, note: body.note ?? null, by }),
|
||||
resolved_by: by,
|
||||
resolved_at: new Date(),
|
||||
})
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await rt().ctx.outbox.emit(
|
||||
trx,
|
||||
"decision.updated",
|
||||
{ decisionId: r.id, status },
|
||||
{
|
||||
idempotencyKey: `decision.updated:${r.id}:${status}`,
|
||||
},
|
||||
);
|
||||
await audit(
|
||||
trx,
|
||||
actor,
|
||||
"decision.resolved",
|
||||
{ type: "decision", id: r.id },
|
||||
{ optionKey: body.optionKey ?? null, status },
|
||||
);
|
||||
return r;
|
||||
});
|
||||
return toApi(row);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
type ContentUnit,
|
||||
cursorPage,
|
||||
EntityIdentitySet,
|
||||
EntityMetadataField,
|
||||
type ExternalIdentity,
|
||||
FieldOverrideInput,
|
||||
Id,
|
||||
IdentityCandidate,
|
||||
IdentityPoolItem,
|
||||
IdentityPoolQuery,
|
||||
IdParam,
|
||||
type MediaEntity,
|
||||
MediaEntityDetail,
|
||||
OkResult,
|
||||
ReleaseIdentityAssign,
|
||||
type TitleAlias,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import { z } from "zod";
|
||||
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 type { SourcesRuntime } from "../runtime.ts";
|
||||
import { audit } from "../service/common.ts";
|
||||
import { resolveEntityId } from "../service/entities.ts";
|
||||
import {
|
||||
assignReleaseIdentity,
|
||||
dismissPoolEntry,
|
||||
mergeEntities,
|
||||
setEntityIdentity,
|
||||
updateExternalIdentity,
|
||||
} from "../service/identity-admin.ts";
|
||||
import { clearFieldOverride, setFieldOverride } from "../service/metadata.ts";
|
||||
|
||||
// 身份与元数据 API:作品(列表/详情/纠错/合并/字段覆盖)、内容单元、别名、外部身份、待识别池。
|
||||
|
||||
const T_ENT = ["media-entities"];
|
||||
const T_ID = ["identity"];
|
||||
const Accepted = z.object({ taskId: Id });
|
||||
const FieldParam = z.object({ id: Id, field: EntityMetadataField });
|
||||
|
||||
export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const db = () => rt().ctx.db;
|
||||
|
||||
// ── 作品 ──
|
||||
route(app, D.mediaEntities.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("media_entity").selectAll();
|
||||
if (query.kind) q = q.where("kind", "=", query.kind);
|
||||
q = query.status ? q.where("status", "=", query.status) : q.where("status", "<>", "merged");
|
||||
if (query.q) {
|
||||
const n = normalizeTitle(query.q);
|
||||
q = q.where((eb) =>
|
||||
eb.or([
|
||||
eb("title", "ilike", `%${query.q}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("title_alias as a")
|
||||
.select("a.id")
|
||||
.whereRef("a.media_entity_id", "=", "media_entity.id")
|
||||
.where(sql<boolean>`(a.normalized = ${n} or similarity(a.normalized, ${n}) >= 0.4)`),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
const rows = await q
|
||||
.orderBy("updated_at", "desc")
|
||||
.orderBy("id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<MediaEntity>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.mediaEntities.get, async ({ params }) => {
|
||||
const id = await resolveEntityId(db(), params.id);
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
return toApi(await db().selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow());
|
||||
});
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/detail",
|
||||
scope: "read",
|
||||
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
response: MediaEntityDetail,
|
||||
},
|
||||
async ({ params }) => {
|
||||
const id = await resolveEntityId(db(), params.id);
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
const [entity, identities, aliases, units, fields, overrides] = await Promise.all([
|
||||
db().selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow(),
|
||||
db()
|
||||
.selectFrom("external_identity")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", id)
|
||||
.orderBy("created_at")
|
||||
.execute(),
|
||||
db().selectFrom("title_alias").selectAll().where("media_entity_id", "=", id).orderBy("created_at").execute(),
|
||||
db()
|
||||
.selectFrom("content_unit")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", id)
|
||||
.orderBy("season_number")
|
||||
.orderBy("episode_number")
|
||||
.execute(),
|
||||
db().selectFrom("metadata_field").selectAll().where("media_entity_id", "=", id).orderBy("field").execute(),
|
||||
db().selectFrom("field_override").selectAll().where("media_entity_id", "=", id).orderBy("field").execute(),
|
||||
]);
|
||||
return {
|
||||
entity: toApi(entity),
|
||||
redirectedFrom: id === params.id ? null : params.id,
|
||||
identities: toApiList(identities),
|
||||
aliases: toApiList(aliases),
|
||||
contentUnits: toApiList(units),
|
||||
metadataFields: toApiList(fields),
|
||||
overrides: toApiList(overrides),
|
||||
};
|
||||
},
|
||||
);
|
||||
route(app, D.mediaEntities.create, async ({ body, actor }) => {
|
||||
const row = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const r = await trx
|
||||
.insertInto("media_entity")
|
||||
.values({
|
||||
kind: body.kind,
|
||||
title: body.title,
|
||||
original_title: body.originalTitle ?? null,
|
||||
year: body.year ?? null,
|
||||
status: body.status === "merged" ? "active" : (body.status ?? "active"),
|
||||
metadata: jsonb(body.metadata ?? {}),
|
||||
field_locks: body.fieldLocks ?? [],
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await sql`insert into title_alias (media_entity_id, alias, normalized, kind, source, locked)
|
||||
values (${r.id}, ${body.title}, ${normalizeTitle(body.title) || body.title}, 'translated', 'user', true)
|
||||
on conflict do nothing`.execute(trx);
|
||||
await audit(trx, actor, "media_entity.created", { type: "media_entity", id: r.id }, { title: body.title });
|
||||
return r;
|
||||
});
|
||||
return toApi(row);
|
||||
});
|
||||
route(app, D.mediaEntities.update, async ({ params, body, actor }) => {
|
||||
const id = await resolveEntityId(db(), params.id);
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
// 标题/原名/年份按字段覆盖处理(加锁),其余字段直接更新
|
||||
const locked: [z.infer<typeof EntityMetadataField>, unknown][] = [];
|
||||
if (body.title !== undefined) locked.push(["title", body.title]);
|
||||
if (body.originalTitle !== undefined) locked.push(["originalTitle", body.originalTitle]);
|
||||
if (body.year !== undefined) locked.push(["year", body.year]);
|
||||
for (const [field, value] of locked) await setFieldOverride(rt(), id, field, { value, lock: true }, actor);
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.kind !== undefined) patch.kind = body.kind;
|
||||
if (body.status !== undefined) patch.status = body.status;
|
||||
if (Object.keys(patch).length) {
|
||||
await db().updateTable("media_entity").set(patch).where("id", "=", id).execute();
|
||||
await audit(db(), actor, "media_entity.updated", { type: "media_entity", id }, patch);
|
||||
}
|
||||
return toApi(await db().selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow());
|
||||
});
|
||||
route(app, D.mediaEntities.merge, async ({ params, body, actor }) =>
|
||||
toApi(await mergeEntities(rt(), params.id, body.intoId, body.reason ?? null, actor)),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/identities",
|
||||
scope: "organize.execute",
|
||||
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: EntityIdentitySet,
|
||||
response: z.object({
|
||||
mediaEntityId: Id,
|
||||
changed: z.boolean(),
|
||||
identityId: Id.optional(),
|
||||
retired: z.array(Id).optional(),
|
||||
}),
|
||||
},
|
||||
async ({ params, body, actor }) => setEntityIdentity(rt(), params.id, body, actor),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/refresh",
|
||||
scope: "organize.execute",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
},
|
||||
async ({ params }) => {
|
||||
const id = await resolveEntityId(db(), params.id);
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"refreshMetadata",
|
||||
{ mediaEntityId: id },
|
||||
{ idempotencyKey: `refreshMetadata:${id}:${Math.floor(Date.now() / 60_000)}`, traceId: randomUUID() },
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
},
|
||||
);
|
||||
const FieldResult = z.object({
|
||||
mediaEntityId: Id,
|
||||
changedFields: z.array(z.string()),
|
||||
lockedFields: z.array(z.string()),
|
||||
});
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "put",
|
||||
path: "/media-entities/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
|
||||
tags: T_ENT,
|
||||
params: FieldParam,
|
||||
body: FieldOverrideInput,
|
||||
response: FieldResult,
|
||||
},
|
||||
async ({ params, body, actor }) => setFieldOverride(rt(), params.id, params.field, body, actor),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "delete",
|
||||
path: "/media-entities/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "取消字段覆盖与锁定",
|
||||
tags: T_ENT,
|
||||
params: FieldParam,
|
||||
response: FieldResult,
|
||||
},
|
||||
async ({ params, actor }) => clearFieldOverride(rt(), params.id, params.field, actor),
|
||||
);
|
||||
|
||||
// ── 内容单元 ──
|
||||
route(app, D.contentUnits.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("content_unit").selectAll();
|
||||
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
|
||||
if (query.seasonNumber !== undefined) q = q.where("season_number", "=", query.seasonNumber);
|
||||
const rows = await q
|
||||
.orderBy("media_entity_id")
|
||||
.orderBy("season_number")
|
||||
.orderBy("episode_number")
|
||||
.orderBy("id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<ContentUnit>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.contentUnits.get, async ({ params }) => {
|
||||
const row = await db().selectFrom("content_unit").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "内容单元不存在");
|
||||
return toApi(row);
|
||||
});
|
||||
route(app, D.contentUnits.update, async ({ params, body, actor }) => {
|
||||
const unit = await db().selectFrom("content_unit").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!unit) throw new ApiException("not_found", "内容单元不存在");
|
||||
const numbering = ["seasonNumber", "episodeNumber", "sourceIssue", "airDate"] as const;
|
||||
const touchesNumbering = numbering.some((k) => body[k] !== undefined);
|
||||
const patch: Record<string, unknown> = {};
|
||||
const map: Record<string, string> = {
|
||||
parentUnitId: "parent_unit_id",
|
||||
kind: "kind",
|
||||
contentType: "content_type",
|
||||
segment: "segment",
|
||||
seasonNumber: "season_number",
|
||||
episodeNumber: "episode_number",
|
||||
sourceIssue: "source_issue",
|
||||
airDate: "air_date",
|
||||
displayOrder: "display_order",
|
||||
title: "title",
|
||||
status: "status",
|
||||
numberingLocked: "numbering_locked",
|
||||
fieldLocks: "field_locks",
|
||||
};
|
||||
for (const [k, col] of Object.entries(map)) {
|
||||
const v = (body as Record<string, unknown>)[k];
|
||||
if (v !== undefined) patch[col] = v;
|
||||
}
|
||||
if (body.metadata !== undefined) patch.metadata = jsonb(body.metadata);
|
||||
// 人工改编号即确认并锁定编号(REQ §5.4:编号可修订,人工确认后刷新不得改)
|
||||
if (touchesNumbering && body.numberingLocked === undefined) patch.numbering_locked = true;
|
||||
if (touchesNumbering && body.status === undefined && unit.status === "provisional") patch.status = "confirmed";
|
||||
const row = await db()
|
||||
.updateTable("content_unit")
|
||||
.set(patch)
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(
|
||||
db(),
|
||||
actor,
|
||||
"content_unit.updated",
|
||||
{ type: "content_unit", id: params.id },
|
||||
{ fields: Object.keys(patch) },
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
|
||||
// ── 别名 ──
|
||||
route(app, D.titleAliases.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("title_alias").selectAll();
|
||||
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
|
||||
if (query.q) {
|
||||
const n = normalizeTitle(query.q);
|
||||
q = q.where(sql<boolean>`(normalized = ${n} or similarity(normalized, ${n}) >= 0.3)`);
|
||||
}
|
||||
const rows = await q
|
||||
.orderBy("created_at")
|
||||
.orderBy("id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<TitleAlias>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.titleAliases.create, async ({ body, actor }) => {
|
||||
const id = await resolveEntityId(db(), body.mediaEntityId);
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
const normalized = normalizeTitle(body.alias);
|
||||
if (!normalized) throw new ApiException("bad_request", "别名归一化后为空");
|
||||
try {
|
||||
const row = await db()
|
||||
.insertInto("title_alias")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
content_unit_id: body.contentUnitId ?? null,
|
||||
alias: body.alias,
|
||||
normalized,
|
||||
language: body.language ?? null,
|
||||
kind: body.kind ?? "other",
|
||||
source: body.source,
|
||||
locked: body.locked ?? body.source === "user",
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "title_alias.created", { type: "title_alias", id: row.id }, { alias: body.alias });
|
||||
return toApi(row);
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "23505") throw new ApiException("conflict", "别名已存在");
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
route(app, D.titleAliases.remove, async ({ params, actor }) => {
|
||||
const r = await db().deleteFrom("title_alias").where("id", "=", params.id).returning(["id"]).executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "别名不存在");
|
||||
await audit(db(), actor, "title_alias.deleted", { type: "title_alias", id: params.id });
|
||||
return OkResult.parse({ ok: true });
|
||||
});
|
||||
|
||||
// ── 外部身份 ──
|
||||
route(app, D.externalIdentities.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("external_identity").selectAll();
|
||||
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
|
||||
if (query.provider) q = q.where("provider", "=", query.provider);
|
||||
if (query.status) q = q.where("status", "=", query.status);
|
||||
const rows = await q
|
||||
.orderBy("created_at")
|
||||
.orderBy("id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<ExternalIdentity>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.externalIdentities.get, async ({ params }) => {
|
||||
const row = await db().selectFrom("external_identity").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "外部身份不存在");
|
||||
return toApi(row);
|
||||
});
|
||||
route(app, D.externalIdentities.create, async ({ body, actor }) => {
|
||||
if (!body.mediaEntityId) throw new ApiException("bad_request", "v0.1 只支持作品级外部身份");
|
||||
const entityId = await resolveEntityId(db(), body.mediaEntityId);
|
||||
if (!entityId) throw new ApiException("not_found", "作品不存在");
|
||||
if ((body.status ?? "proposed") === "accepted") {
|
||||
const r = await setEntityIdentity(
|
||||
rt(),
|
||||
entityId,
|
||||
{
|
||||
provider: body.provider,
|
||||
entityType: body.entityType as "movie" | "tv",
|
||||
externalId: body.externalId,
|
||||
lock: body.locked ?? true,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
if (!r.identityId) throw new ApiException("conflict", "该外部身份已是此作品的 accepted 映射");
|
||||
return toApi(
|
||||
await db().selectFrom("external_identity").selectAll().where("id", "=", r.identityId).executeTakeFirstOrThrow(),
|
||||
);
|
||||
}
|
||||
const row = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const r = await trx
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
provider: body.provider,
|
||||
entity_type: body.entityType,
|
||||
external_id: body.externalId,
|
||||
status: body.status ?? "proposed",
|
||||
evidence_level: body.evidenceLevel ?? "manual",
|
||||
evidence: jsonb(body.evidence ?? {}),
|
||||
locked: body.locked ?? false,
|
||||
extra: jsonb(body.extra ?? {}),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await trx
|
||||
.insertInto("identity_revision")
|
||||
.values({
|
||||
external_identity_id: r.id,
|
||||
revision: 1,
|
||||
action: "created",
|
||||
to_status: r.status,
|
||||
after: jsonb({ status: r.status }),
|
||||
actor_type: actor.type,
|
||||
actor_id: actor.id,
|
||||
})
|
||||
.execute();
|
||||
return r;
|
||||
});
|
||||
return toApi(row);
|
||||
});
|
||||
route(app, D.externalIdentities.update, async ({ params, body, actor }) =>
|
||||
toApi(await updateExternalIdentity(rt(), params.id, body, actor)),
|
||||
);
|
||||
route(app, D.externalIdentities.revisions, async ({ params }) => {
|
||||
const rows = await db()
|
||||
.selectFrom("identity_revision")
|
||||
.selectAll()
|
||||
.where("external_identity_id", "=", params.id)
|
||||
.orderBy("revision")
|
||||
.execute();
|
||||
return { items: toApiList(rows) };
|
||||
});
|
||||
|
||||
// ── 待识别池 ──
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/identity-pool",
|
||||
scope: "read",
|
||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
||||
tags: T_ID,
|
||||
query: IdentityPoolQuery,
|
||||
response: cursorPage(IdentityPoolItem),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db()
|
||||
.selectFrom("identity_candidate as p")
|
||||
.innerJoin("source_release as r", "r.id", "p.source_release_id")
|
||||
.selectAll("p")
|
||||
.select([
|
||||
"r.site_id as r_site_id",
|
||||
"r.site_release_id as r_site_release_id",
|
||||
"r.title as r_title",
|
||||
"r.subtitle as r_subtitle",
|
||||
"r.category_raw as r_category_raw",
|
||||
"r.first_seen_at as r_first_seen_at",
|
||||
"r.resolution_status as r_resolution_status",
|
||||
]);
|
||||
q = q.where("p.status", "=", query.status ?? "pending");
|
||||
if (query.reason) q = q.where("p.reason", "=", query.reason);
|
||||
if (query.siteId) q = q.where("r.site_id", "=", query.siteId);
|
||||
if (query.q) {
|
||||
const n = normalizeTitle(query.q);
|
||||
q = q.where((eb) =>
|
||||
eb.or([
|
||||
eb("r.title", "ilike", `%${query.q}%`),
|
||||
eb("r.subtitle", "ilike", `%${query.q}%`),
|
||||
sql<boolean>`(p.query->>'normalized') = ${n}`,
|
||||
sql<boolean>`similarity(r.title, ${query.q}) >= 0.3`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
const rows = await q
|
||||
.orderBy("p.updated_at", "desc")
|
||||
.orderBy("p.id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return {
|
||||
items: p.items.map((row) => {
|
||||
const {
|
||||
r_site_id,
|
||||
r_site_release_id,
|
||||
r_title,
|
||||
r_subtitle,
|
||||
r_category_raw,
|
||||
r_first_seen_at,
|
||||
r_resolution_status,
|
||||
...rest
|
||||
} = row;
|
||||
return {
|
||||
...toApi<Record<string, unknown>>(rest),
|
||||
release: {
|
||||
id: rest.source_release_id,
|
||||
siteId: r_site_id,
|
||||
siteReleaseId: r_site_release_id,
|
||||
title: r_title,
|
||||
subtitle: r_subtitle,
|
||||
categoryRaw: r_category_raw,
|
||||
firstSeenAt: r_first_seen_at.toISOString(),
|
||||
resolutionStatus: r_resolution_status,
|
||||
},
|
||||
};
|
||||
}),
|
||||
nextCursor: p.nextCursor,
|
||||
};
|
||||
},
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/assign",
|
||||
scope: "organize.execute",
|
||||
summary: "为待识别资源手动指定候选 / 本地作品 / 外部身份",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: ReleaseIdentityAssign,
|
||||
response: z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) }),
|
||||
},
|
||||
async ({ params, body, actor }) => {
|
||||
const pool = await db()
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["source_release_id"])
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
||||
return assignReleaseIdentity(rt(), pool.source_release_id, body, actor);
|
||||
},
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/dismiss",
|
||||
scope: "organize.execute",
|
||||
summary: "忽略待识别资源(不再自动复查)",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: z.object({ note: z.string().max(2000).optional() }),
|
||||
response: IdentityCandidate,
|
||||
},
|
||||
async ({ params, body, actor }) => toApi(await dismissPoolEntry(rt(), params.id, actor, body.note ?? null)),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/retry",
|
||||
scope: "organize.execute",
|
||||
summary: "立即重新识别",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
},
|
||||
async ({ params }) => {
|
||||
const pool = await db()
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["source_release_id", "attempts"])
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
{ sourceReleaseId: pool.source_release_id, force: true },
|
||||
{ idempotencyKey: `resolve:retry:${params.id}:${Math.floor(Date.now() / 10_000)}`, traceId: randomUUID() },
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { listSiteDefinitions, redactUrl, type SiteDefinition } from "@umt/adapters";
|
||||
import {
|
||||
CursorQuery,
|
||||
cursorPage,
|
||||
FeedSource,
|
||||
Id,
|
||||
IdParam,
|
||||
type Observation,
|
||||
ReleaseIdentityAssign,
|
||||
type SecretPurpose,
|
||||
Site,
|
||||
type SiteAccount,
|
||||
type SiteAdapter,
|
||||
SiteDefinitionView,
|
||||
SiteSnapshot,
|
||||
type SourceRelease,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
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 type { SourcesRuntime } from "../runtime.ts";
|
||||
import { audit, closeDecisions } from "../service/common.ts";
|
||||
import { assignReleaseIdentity } from "../service/identity-admin.ts";
|
||||
|
||||
// 站点、站点账号、Feed、来源资源、观察的管理 API。凭证只写(存 secret,对外只给 secret_ref / 脱敏 URL)。
|
||||
|
||||
const T_SITES = ["sites"];
|
||||
const T_FEEDS = ["feed-sources"];
|
||||
const T_RELEASES = ["source-releases"];
|
||||
|
||||
export function adapterOf(def: SiteDefinition): SiteAdapter {
|
||||
return def.umt.kind === "mteam" ? "mteam_api" : def.umt.kind === "api" ? "api" : "nexusphp";
|
||||
}
|
||||
|
||||
const SECRET_PURPOSE: Record<string, SecretPurpose> = {
|
||||
cookie: "site_cookie",
|
||||
passkey: "site_passkey",
|
||||
api_key: "site_api_key",
|
||||
password: "site_password",
|
||||
};
|
||||
|
||||
const Accepted = z.object({ taskId: Id });
|
||||
|
||||
export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const db = () => rt().ctx.db;
|
||||
|
||||
// ── 站点注册表 ──
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/site-definitions",
|
||||
scope: "read",
|
||||
summary: "随产品发布的站点适配器定义(含是否已登记)",
|
||||
tags: T_SITES,
|
||||
response: z.object({ items: z.array(SiteDefinitionView) }),
|
||||
},
|
||||
async () => {
|
||||
const rows = await db().selectFrom("site").select(["id", "key"]).execute();
|
||||
const byKey = new Map(rows.map((r) => [r.key, r.id]));
|
||||
return {
|
||||
items: listSiteDefinitions().map((d) => ({
|
||||
key: d.id,
|
||||
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
|
||||
adapter: adapterOf(d),
|
||||
baseUrl: d.domain,
|
||||
shippedEnabled: d.enabled !== false,
|
||||
definitionVersion: d.umt.version,
|
||||
capabilities: d.umt.capabilities,
|
||||
defaultRateLimit: d.umt.rateLimit,
|
||||
authSupported: ("authSupported" in d.umt ? d.umt.authSupported : undefined) ?? [],
|
||||
siteId: byKey.get(d.id) ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── 站点 ──
|
||||
route(app, D.sites.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
const rows = await db()
|
||||
.selectFrom("site")
|
||||
.selectAll()
|
||||
.orderBy("key")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<Site>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.sites.get, async ({ params }) => toApi(await getSite(params.id)));
|
||||
route(app, D.sites.create, async ({ body, actor }) => {
|
||||
const def = listSiteDefinitions().find((d) => d.id === body.key);
|
||||
if (def && adapterOf(def) !== body.adapter) {
|
||||
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`);
|
||||
}
|
||||
if (!def && body.adapter !== "generic_rss") {
|
||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
|
||||
}
|
||||
try {
|
||||
const row = await db()
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: body.key,
|
||||
name: body.name,
|
||||
adapter: body.adapter,
|
||||
base_url: body.baseUrl,
|
||||
enabled: body.enabled ?? true,
|
||||
rate_limit: jsonb(body.rateLimit ?? {}),
|
||||
config: jsonb(body.config ?? {}),
|
||||
snapshot_enabled: body.snapshotEnabled ?? false,
|
||||
snapshot_interval_sec: body.snapshotIntervalSec ?? null,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "site.created", { type: "site", id: row.id }, { key: body.key });
|
||||
return toApi(row);
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "23505") throw new ApiException("conflict", `站点 ${body.key} 已登记`);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
route(app, D.sites.update, async ({ params, body, actor }) => {
|
||||
await getSite(params.id);
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) patch.name = body.name;
|
||||
if (body.adapter !== undefined) patch.adapter = body.adapter;
|
||||
if (body.baseUrl !== undefined) patch.base_url = body.baseUrl;
|
||||
if (body.rateLimit !== undefined) patch.rate_limit = jsonb(body.rateLimit);
|
||||
if (body.config !== undefined) patch.config = jsonb(body.config);
|
||||
if (body.snapshotEnabled !== undefined) patch.snapshot_enabled = body.snapshotEnabled;
|
||||
if (body.snapshotIntervalSec !== undefined) patch.snapshot_interval_sec = body.snapshotIntervalSec;
|
||||
if (body.enabled !== undefined) return toApi(await setSiteEnabled(params.id, body.enabled, actor));
|
||||
const row = await db()
|
||||
.updateTable("site")
|
||||
.set(patch)
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "site.updated", { type: "site", id: params.id }, { fields: Object.keys(patch) });
|
||||
return toApi(row);
|
||||
});
|
||||
for (const enabled of [true, false]) {
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: `/sites/{id}/${enabled ? "enable" : "disable"}`,
|
||||
scope: "admin",
|
||||
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: Site,
|
||||
},
|
||||
async ({ params, actor }) => toApi(await setSiteEnabled(params.id, enabled, actor)),
|
||||
);
|
||||
}
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/sites/{id}/snapshot",
|
||||
scope: "admin",
|
||||
summary: "立即同步站点快照(受站点限流)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }),
|
||||
response: Accepted,
|
||||
},
|
||||
async ({ params, body }) => {
|
||||
const s = await getSite(params.id);
|
||||
if (!s.enabled) throw new ApiException("precondition_failed", "站点已关闭");
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"snapshotSite",
|
||||
{ siteId: s.id, kind: "manual", ...(body.maxPages ? { maxPages: body.maxPages } : {}) },
|
||||
{ idempotencyKey: `snapshotSite:manual:${s.id}:${Math.floor(Date.now() / 10_000)}`, traceId: randomUUID() },
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
},
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/sites/{id}/snapshots",
|
||||
scope: "read",
|
||||
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
query: CursorQuery,
|
||||
response: cursorPage(SiteSnapshot),
|
||||
},
|
||||
async ({ params, query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
const rows = await db()
|
||||
.selectFrom("site_snapshot")
|
||||
.selectAll()
|
||||
.where("site_id", "=", params.id)
|
||||
.orderBy("started_at", "desc")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList(p.items), nextCursor: p.nextCursor };
|
||||
},
|
||||
);
|
||||
|
||||
// ── 站点账号 ──
|
||||
route(app, D.siteAccounts.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("site_account").selectAll();
|
||||
if (query.siteId) q = q.where("site_id", "=", query.siteId);
|
||||
const rows = await q
|
||||
.orderBy("created_at")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<SiteAccount>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.siteAccounts.get, async ({ params }) => {
|
||||
const row = await db().selectFrom("site_account").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "站点账号不存在");
|
||||
return toApi(row);
|
||||
});
|
||||
route(app, D.siteAccounts.create, async ({ body, actor }) => {
|
||||
await getSite(body.siteId);
|
||||
const ref = await rt().ctx.secrets.create({
|
||||
purpose: SECRET_PURPOSE[body.authKind]!,
|
||||
plaintext: body.credential,
|
||||
label: `site_account:${body.label}`,
|
||||
});
|
||||
try {
|
||||
const row = await db()
|
||||
.insertInto("site_account")
|
||||
.values({
|
||||
site_id: body.siteId,
|
||||
label: body.label,
|
||||
auth_kind: body.authKind,
|
||||
secret_ref: ref,
|
||||
enabled: body.enabled ?? true,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "site_account.created", { type: "site_account", id: row.id }, { siteId: body.siteId });
|
||||
return toApi(row);
|
||||
} catch (e) {
|
||||
await rt()
|
||||
.ctx.secrets.remove(ref)
|
||||
.catch(() => {});
|
||||
if ((e as { code?: string }).code === "23505") throw new ApiException("conflict", "同站点已有同名账号");
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
route(app, D.siteAccounts.update, async ({ params, body, actor }) => {
|
||||
const acc = await db().selectFrom("site_account").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!acc) throw new ApiException("not_found", "站点账号不存在");
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.label !== undefined) patch.label = body.label;
|
||||
if (body.enabled !== undefined) patch.enabled = body.enabled;
|
||||
if (body.authKind !== undefined) patch.auth_kind = body.authKind;
|
||||
if (body.credential !== undefined) {
|
||||
if (acc.secret_ref) await rt().ctx.secrets.replace(acc.secret_ref, body.credential);
|
||||
else {
|
||||
patch.secret_ref = await rt().ctx.secrets.create({
|
||||
purpose: SECRET_PURPOSE[body.authKind ?? acc.auth_kind]!,
|
||||
plaintext: body.credential,
|
||||
label: `site_account:${acc.label}`,
|
||||
});
|
||||
}
|
||||
// 换了凭证:状态回到未验证,关闭"登录失效"待处理,站点健康复位
|
||||
patch.status = "unknown";
|
||||
}
|
||||
const row = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const r = await trx
|
||||
.updateTable("site_account")
|
||||
.set(patch)
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (body.credential !== undefined) {
|
||||
await trx
|
||||
.updateTable("site")
|
||||
.set({ health_status: "unknown" })
|
||||
.where("id", "=", acc.site_id)
|
||||
.where("health_status", "=", "failing")
|
||||
.execute();
|
||||
await closeDecisions(
|
||||
rt().ctx,
|
||||
trx,
|
||||
`site-auth:${acc.site_id}`,
|
||||
"resolved",
|
||||
{ by: "credential_updated" },
|
||||
`${actor.type}:${actor.id}`,
|
||||
);
|
||||
}
|
||||
await audit(
|
||||
trx,
|
||||
actor,
|
||||
"site_account.updated",
|
||||
{ type: "site_account", id: params.id },
|
||||
{
|
||||
fields: Object.keys(patch).filter((k) => k !== "secret_ref"),
|
||||
credentialRotated: body.credential !== undefined,
|
||||
},
|
||||
);
|
||||
return r;
|
||||
});
|
||||
return toApi(row);
|
||||
});
|
||||
|
||||
// ── Feed ──
|
||||
route(app, D.feedSources.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("feed_source").selectAll();
|
||||
if (query.siteId) q = q.where("site_id", "=", query.siteId);
|
||||
if (query.health) q = q.where("health_status", "=", query.health);
|
||||
const rows = await q
|
||||
.orderBy("created_at")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<FeedSource>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.feedSources.get, async ({ params }) => toApi(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);
|
||||
const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${body.name}` });
|
||||
const row = await db()
|
||||
.insertInto("feed_source")
|
||||
.values({
|
||||
site_id: body.siteId ?? null,
|
||||
site_account_id: body.siteAccountId ?? null,
|
||||
name: body.name,
|
||||
purpose: body.purpose ?? "generic",
|
||||
kind: body.kind ?? "rss",
|
||||
url_secret_ref: ref,
|
||||
url_display: redactUrl(body.url),
|
||||
enabled: body.enabled ?? true,
|
||||
poll_interval_sec: body.pollIntervalSec ?? 900,
|
||||
filter: jsonb(body.filter ?? {}),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "feed_source.created", { type: "feed_source", id: row.id }, { url: row.url_display });
|
||||
return toApi(row);
|
||||
});
|
||||
route(app, D.feedSources.update, async ({ params, body, actor }) => {
|
||||
const feed = await getFeed(params.id);
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.siteId !== undefined) {
|
||||
if (body.siteId) await getSite(body.siteId);
|
||||
patch.site_id = body.siteId;
|
||||
}
|
||||
if (body.siteAccountId !== undefined) {
|
||||
if (body.siteAccountId) await checkAccount(body.siteAccountId, body.siteId ?? feed.site_id);
|
||||
patch.site_account_id = body.siteAccountId;
|
||||
}
|
||||
if (body.name !== undefined) patch.name = body.name;
|
||||
if (body.purpose !== undefined) patch.purpose = body.purpose;
|
||||
if (body.kind !== undefined) patch.kind = body.kind;
|
||||
if (body.enabled !== undefined) {
|
||||
patch.enabled = body.enabled;
|
||||
patch.health_status = body.enabled ? "unknown" : "disabled";
|
||||
}
|
||||
if (body.pollIntervalSec !== undefined) patch.poll_interval_sec = body.pollIntervalSec;
|
||||
if (body.filter !== undefined) patch.filter = jsonb(body.filter);
|
||||
if (body.url !== undefined) {
|
||||
if (feed.url_secret_ref) await rt().ctx.secrets.replace(feed.url_secret_ref, body.url);
|
||||
else
|
||||
patch.url_secret_ref = await rt().ctx.secrets.create({
|
||||
purpose: "feed_url",
|
||||
plaintext: body.url,
|
||||
label: `feed:${feed.name}`,
|
||||
});
|
||||
patch.url_display = redactUrl(body.url);
|
||||
// 换了链接:清空条件请求缓存(游标保留,用于去重)
|
||||
patch.etag = null;
|
||||
patch.last_modified = null;
|
||||
}
|
||||
const row = await db()
|
||||
.updateTable("feed_source")
|
||||
.set(patch)
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(
|
||||
db(),
|
||||
actor,
|
||||
"feed_source.updated",
|
||||
{ type: "feed_source", id: params.id },
|
||||
{
|
||||
fields: Object.keys(patch).filter((k) => k !== "url_secret_ref"),
|
||||
},
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
for (const enabled of [true, false]) {
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: `/feed-sources/{id}/${enabled ? "enable" : "disable"}`,
|
||||
scope: "admin",
|
||||
summary: enabled ? "启用 Feed" : "停用 Feed",
|
||||
tags: T_FEEDS,
|
||||
params: IdParam,
|
||||
response: FeedSource,
|
||||
},
|
||||
async ({ params, actor }) => {
|
||||
await getFeed(params.id);
|
||||
const row = await db()
|
||||
.updateTable("feed_source")
|
||||
.set({
|
||||
enabled,
|
||||
health_status: enabled ? "unknown" : "disabled",
|
||||
...(enabled ? { next_fetch_at: new Date() } : {}),
|
||||
})
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, enabled ? "feed_source.enabled" : "feed_source.disabled", {
|
||||
type: "feed_source",
|
||||
id: params.id,
|
||||
});
|
||||
return toApi(row);
|
||||
},
|
||||
);
|
||||
}
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/feed-sources/{id}/reset",
|
||||
scope: "admin",
|
||||
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
|
||||
tags: T_FEEDS,
|
||||
params: IdParam,
|
||||
response: FeedSource,
|
||||
},
|
||||
async ({ params, actor }) => {
|
||||
const feed = await getFeed(params.id);
|
||||
const cursor = (feed.cursor ?? {}) as { window?: unknown };
|
||||
const row = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const r = await trx
|
||||
.updateTable("feed_source")
|
||||
.set({
|
||||
consecutive_failures: 0,
|
||||
backoff_until: null,
|
||||
last_error: null,
|
||||
health_status: feed.enabled ? "unknown" : "disabled",
|
||||
next_fetch_at: new Date(),
|
||||
cursor: jsonb({ window: cursor.window ?? null }),
|
||||
})
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (feed.site_account_id) {
|
||||
await trx
|
||||
.updateTable("site_account")
|
||||
.set({ status: "unknown" })
|
||||
.where("id", "=", feed.site_account_id)
|
||||
.where("status", "in", ["invalid", "expired"])
|
||||
.execute();
|
||||
}
|
||||
await closeDecisions(
|
||||
rt().ctx,
|
||||
trx,
|
||||
`feed-auth:${params.id}`,
|
||||
"resolved",
|
||||
{ by: "reset" },
|
||||
`${actor.type}:${actor.id}`,
|
||||
);
|
||||
await audit(trx, actor, "feed_source.reset", { type: "feed_source", id: params.id });
|
||||
return r;
|
||||
});
|
||||
return toApi(row);
|
||||
},
|
||||
);
|
||||
route(app, D.feedSources.fetchNow, async ({ params }) => {
|
||||
const feed = await getFeed(params.id);
|
||||
if (!feed.enabled) throw new ApiException("precondition_failed", "Feed 已停用");
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"ingestFeed",
|
||||
{ feedSourceId: feed.id, manual: true },
|
||||
{ idempotencyKey: `ingestFeed:manual:${feed.id}:${Math.floor(Date.now() / 10_000)}`, traceId: randomUUID() },
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
});
|
||||
|
||||
// ── 来源资源 / 观察 ──
|
||||
route(app, D.sourceReleases.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("source_release").selectAll();
|
||||
if (query.siteId) q = q.where("site_id", "=", query.siteId);
|
||||
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
|
||||
if (query.resolutionStatus) q = q.where("resolution_status", "=", query.resolutionStatus);
|
||||
if (query.q)
|
||||
q = q.where((eb) =>
|
||||
eb.or([eb("title", "ilike", `%${query.q}%`), sql<boolean>`similarity(title, ${query.q}) >= 0.3`]),
|
||||
);
|
||||
const rows = await q
|
||||
.orderBy("first_seen_at", "desc")
|
||||
.orderBy("id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<SourceRelease>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.sourceReleases.get, async ({ params }) => {
|
||||
const row = await db().selectFrom("source_release").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "来源资源不存在");
|
||||
return toApi(row);
|
||||
});
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/source-releases/{id}/identity",
|
||||
scope: "organize.execute",
|
||||
summary: "人工指定来源资源的作品/内容单元(加人工锁,自动识别不再改写)",
|
||||
tags: T_RELEASES,
|
||||
params: IdParam,
|
||||
body: ReleaseIdentityAssign,
|
||||
response: z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) }),
|
||||
},
|
||||
async ({ params, body, actor }) => assignReleaseIdentity(rt(), params.id, body, actor),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/source-releases/{id}/unlock",
|
||||
scope: "organize.execute",
|
||||
summary: "解除来源资源的人工锁并重新识别",
|
||||
tags: T_RELEASES,
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
},
|
||||
async ({ params, actor }) => {
|
||||
const row = await db()
|
||||
.updateTable("source_release")
|
||||
.set({ resolution_locked: false })
|
||||
.where("id", "=", params.id)
|
||||
.returning(["id", "site_id"])
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "来源资源不存在");
|
||||
await audit(db(), actor, "identity.release_unlocked", { type: "source_release", id: params.id });
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
{ sourceReleaseId: params.id, force: true },
|
||||
{ idempotencyKey: `resolve:unlock:${params.id}:${Date.now()}`, traceId: randomUUID() },
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
},
|
||||
);
|
||||
route(app, D.observations.list, async ({ query }) => {
|
||||
const off = decodeCursor(query.cursor);
|
||||
let q = db().selectFrom("observation").selectAll();
|
||||
if (query.feedId) q = q.where("feed_id", "=", query.feedId);
|
||||
if (query.status) q = q.where("status", "=", query.status);
|
||||
const rows = await q
|
||||
.orderBy("first_seen_at", "desc")
|
||||
.orderBy("id")
|
||||
.offset(off)
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
return { items: toApiList<Observation>(p.items), nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.observations.get, async ({ params }) => {
|
||||
const row = await db().selectFrom("observation").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "观察不存在");
|
||||
return toApi(row);
|
||||
});
|
||||
|
||||
// ── 内部辅助 ──
|
||||
async function getSite(id: string) {
|
||||
const row = await db().selectFrom("site").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "站点不存在");
|
||||
return row;
|
||||
}
|
||||
async function getFeed(id: string) {
|
||||
const row = await db().selectFrom("feed_source").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "Feed 不存在");
|
||||
return row;
|
||||
}
|
||||
async function checkAccount(accountId: string, siteId: string | null) {
|
||||
const acc = await db()
|
||||
.selectFrom("site_account")
|
||||
.select(["site_id"])
|
||||
.where("id", "=", accountId)
|
||||
.executeTakeFirst();
|
||||
if (!acc) throw new ApiException("bad_request", "站点账号不存在");
|
||||
if (siteId && acc.site_id !== siteId) throw new ApiException("bad_request", "站点账号不属于该站点");
|
||||
}
|
||||
async function setSiteEnabled(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
actor: { type: "user" | "agent" | "system" | "api_key"; id: string | null },
|
||||
) {
|
||||
await getSite(id);
|
||||
return db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const row = await trx
|
||||
.updateTable("site")
|
||||
.set({ enabled, health_status: enabled ? "unknown" : "disabled" })
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
// 历史资源仍可查,标注站点关闭
|
||||
await trx
|
||||
.updateTable("source_release")
|
||||
.set({ availability: enabled ? "active" : "site_disabled" })
|
||||
.where("site_id", "=", id)
|
||||
.where("availability", "=", enabled ? "site_disabled" : "active")
|
||||
.execute();
|
||||
await audit(trx, actor, enabled ? "site.enabled" : "site.disabled", { type: "site", id });
|
||||
return row;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Id, WorkbenchReleaseDetail, WorkbenchReleasePage, WorkbenchReleaseQuery } from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { route } from "../http.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { listSiteReleases, releaseDetail } from "../service/workbench.ts";
|
||||
|
||||
// 站点工作台(整理视图)数据 API。库存叠加(已有资产/Slot/下载)不在这里:条目带 entity.id,
|
||||
// 前端按作品 ID 二次查询 W2-C 的媒体库端点。
|
||||
|
||||
const T = ["site-workbench"];
|
||||
|
||||
export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases",
|
||||
scope: "read",
|
||||
summary: "站点资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
|
||||
tags: T,
|
||||
params: z.object({ id: Id }),
|
||||
query: WorkbenchReleaseQuery,
|
||||
response: WorkbenchReleasePage,
|
||||
},
|
||||
async ({ params, query }) => listSiteReleases(rt(), params.id, query),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases/{rid}",
|
||||
scope: "read",
|
||||
summary: "站点资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
|
||||
tags: T,
|
||||
params: z.object({ id: Id, rid: Id }),
|
||||
query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
|
||||
response: WorkbenchReleaseDetail,
|
||||
},
|
||||
async ({ params, query }) => releaseDetail(rt(), params.id, params.rid, query.refresh === "true"),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user