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:
rootandClaude Opus 5.5 committed 2026-09-27 20:12:01 +08:00
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"),
);
}