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,303 @@
|
||||
import {
|
||||
ContentUnit,
|
||||
ContentUnitUpdate,
|
||||
CursorQuery,
|
||||
cursorPage,
|
||||
Decision,
|
||||
DecisionKind,
|
||||
DecisionResolveInput,
|
||||
DecisionStatus,
|
||||
ExternalIdentity,
|
||||
ExternalIdentityCreate,
|
||||
ExternalIdentityUpdate,
|
||||
FeedHealth,
|
||||
FeedSource,
|
||||
FeedSourceCreate,
|
||||
FeedSourceUpdate,
|
||||
Id,
|
||||
IdentityRevision,
|
||||
IdentityStatus,
|
||||
IdParam,
|
||||
MediaEntity,
|
||||
MediaEntityCreate,
|
||||
MediaEntityMerge,
|
||||
MediaEntityStatus,
|
||||
MediaEntityUpdate,
|
||||
MediaKind,
|
||||
Observation,
|
||||
ObservationStatus,
|
||||
OkResult,
|
||||
ResolutionStatus,
|
||||
Site,
|
||||
SiteAccount,
|
||||
SiteAccountCreate,
|
||||
SiteAccountUpdate,
|
||||
SiteCreate,
|
||||
SiteUpdate,
|
||||
SourceRelease,
|
||||
TitleAlias,
|
||||
TitleAliasCreate,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
|
||||
// 与 contracts Api(crud())同路径、同 scope 的端点定义,但保留具体 schema 类型供路由推断入参。
|
||||
// scope:读 = read;写 = admin(站点/Feed 配置)或 organize.execute(身份纠错、待处理)。
|
||||
|
||||
const list = <R extends z.ZodType, F extends z.ZodRawShape>(
|
||||
path: string,
|
||||
summary: string,
|
||||
row: R,
|
||||
filters: F,
|
||||
tags: string[],
|
||||
) => ({
|
||||
method: "get" as const,
|
||||
path,
|
||||
scope: "read" as const,
|
||||
summary,
|
||||
tags,
|
||||
query: CursorQuery.extend(filters),
|
||||
response: cursorPage(row),
|
||||
});
|
||||
const get = <R extends z.ZodType>(path: string, summary: string, row: R, tags: string[]) => ({
|
||||
method: "get" as const,
|
||||
path,
|
||||
scope: "read" as const,
|
||||
summary,
|
||||
tags,
|
||||
params: IdParam,
|
||||
response: row,
|
||||
});
|
||||
const write = <M extends "post" | "patch" | "delete", B extends z.ZodType | undefined, R extends z.ZodType>(
|
||||
method: M,
|
||||
path: string,
|
||||
scope: "admin" | "organize.execute",
|
||||
summary: string,
|
||||
body: B,
|
||||
response: R,
|
||||
tags: string[],
|
||||
withId = true,
|
||||
) => ({
|
||||
method,
|
||||
path,
|
||||
scope,
|
||||
summary,
|
||||
tags,
|
||||
...(withId ? { params: IdParam } : {}),
|
||||
body: body as B,
|
||||
response,
|
||||
});
|
||||
|
||||
const TS = ["sites"];
|
||||
const TF = ["feed-sources"];
|
||||
const TR = ["source-releases"];
|
||||
const TE = ["media-entities"];
|
||||
const TI = ["identity"];
|
||||
const TD = ["decisions"];
|
||||
|
||||
export const D = {
|
||||
sites: {
|
||||
list: list("/sites", "站点列表", Site, {}, TS),
|
||||
get: get("/sites/{id}", "站点详情", Site, TS),
|
||||
create: {
|
||||
method: "post" as const,
|
||||
path: "/sites",
|
||||
scope: "admin" as const,
|
||||
summary: "登记站点",
|
||||
tags: TS,
|
||||
body: SiteCreate,
|
||||
response: Site,
|
||||
},
|
||||
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流)", SiteUpdate, Site, TS),
|
||||
},
|
||||
siteAccounts: {
|
||||
list: list("/site-accounts", "站点账号列表", SiteAccount, { siteId: Id.optional() }, TS),
|
||||
get: get("/site-accounts/{id}", "站点账号详情", SiteAccount, TS),
|
||||
create: {
|
||||
method: "post" as const,
|
||||
path: "/site-accounts",
|
||||
scope: "admin" as const,
|
||||
summary: "新建站点账号(凭证只写,存为 secret)",
|
||||
tags: TS,
|
||||
body: SiteAccountCreate,
|
||||
response: SiteAccount,
|
||||
},
|
||||
update: write(
|
||||
"patch",
|
||||
"/site-accounts/{id}",
|
||||
"admin",
|
||||
"修改站点账号 / 轮换凭证",
|
||||
SiteAccountUpdate,
|
||||
SiteAccount,
|
||||
TS,
|
||||
),
|
||||
},
|
||||
feedSources: {
|
||||
list: list("/feed-sources", "Feed 列表", FeedSource, { siteId: Id.optional(), health: FeedHealth.optional() }, TF),
|
||||
get: get("/feed-sources/{id}", "Feed 详情", FeedSource, TF),
|
||||
create: {
|
||||
method: "post" as const,
|
||||
path: "/feed-sources",
|
||||
scope: "admin" as const,
|
||||
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
|
||||
tags: TF,
|
||||
body: FeedSourceCreate,
|
||||
response: FeedSource,
|
||||
},
|
||||
update: write("patch", "/feed-sources/{id}", "admin", "修改 Feed", FeedSourceUpdate, FeedSource, TF),
|
||||
fetchNow: write(
|
||||
"post",
|
||||
"/feed-sources/{id}/fetch",
|
||||
"admin",
|
||||
"立即抓取(仍受站点限流)",
|
||||
undefined,
|
||||
z.object({ taskId: Id }),
|
||||
TF,
|
||||
),
|
||||
},
|
||||
sourceReleases: {
|
||||
list: list(
|
||||
"/source-releases",
|
||||
"站点资源列表",
|
||||
SourceRelease,
|
||||
{
|
||||
siteId: Id.optional(),
|
||||
mediaEntityId: Id.optional(),
|
||||
resolutionStatus: ResolutionStatus.optional(),
|
||||
q: z.string().max(200).optional(),
|
||||
},
|
||||
TR,
|
||||
),
|
||||
get: get("/source-releases/{id}", "站点资源详情", SourceRelease, TR),
|
||||
},
|
||||
observations: {
|
||||
list: list(
|
||||
"/observations",
|
||||
"观察列表",
|
||||
Observation,
|
||||
{ feedId: Id.optional(), status: ObservationStatus.optional() },
|
||||
TR,
|
||||
),
|
||||
get: get("/observations/{id}", "观察详情", Observation, TR),
|
||||
},
|
||||
mediaEntities: {
|
||||
list: list(
|
||||
"/media-entities",
|
||||
"作品列表",
|
||||
MediaEntity,
|
||||
{ kind: MediaKind.optional(), status: MediaEntityStatus.optional(), q: z.string().max(200).optional() },
|
||||
TE,
|
||||
),
|
||||
get: get("/media-entities/{id}", "作品(旧 ID 自动跳转)", MediaEntity, TE),
|
||||
create: {
|
||||
method: "post" as const,
|
||||
path: "/media-entities",
|
||||
scope: "organize.execute" as const,
|
||||
summary: "手动建本地作品(TMDB 无记录时可先建档)",
|
||||
tags: TE,
|
||||
body: MediaEntityCreate,
|
||||
response: MediaEntity,
|
||||
},
|
||||
update: write(
|
||||
"patch",
|
||||
"/media-entities/{id}",
|
||||
"organize.execute",
|
||||
"修改作品(标题/原名/年份按字段覆盖并锁定)",
|
||||
MediaEntityUpdate,
|
||||
MediaEntity,
|
||||
TE,
|
||||
),
|
||||
merge: write(
|
||||
"post",
|
||||
"/media-entities/{id}/merge",
|
||||
"organize.execute",
|
||||
"合并重复作品(保留旧 ID 跳转)",
|
||||
MediaEntityMerge,
|
||||
MediaEntity,
|
||||
TE,
|
||||
),
|
||||
},
|
||||
contentUnits: {
|
||||
list: list(
|
||||
"/content-units",
|
||||
"内容单元列表",
|
||||
ContentUnit,
|
||||
{ mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() },
|
||||
TE,
|
||||
),
|
||||
get: get("/content-units/{id}", "内容单元详情", ContentUnit, TE),
|
||||
update: write(
|
||||
"patch",
|
||||
"/content-units/{id}",
|
||||
"organize.execute",
|
||||
"修改内容单元(改编号即确认并锁定)",
|
||||
ContentUnitUpdate,
|
||||
ContentUnit,
|
||||
TE,
|
||||
),
|
||||
},
|
||||
titleAliases: {
|
||||
list: list(
|
||||
"/title-aliases",
|
||||
"别名列表",
|
||||
TitleAlias,
|
||||
{ mediaEntityId: Id.optional(), q: z.string().max(200).optional() },
|
||||
TI,
|
||||
),
|
||||
create: write("post", "/title-aliases", "organize.execute", "新增别名", TitleAliasCreate, TitleAlias, TI, false),
|
||||
remove: write("delete", "/title-aliases/{id}", "organize.execute", "删除别名", undefined, OkResult, TI),
|
||||
},
|
||||
externalIdentities: {
|
||||
list: list(
|
||||
"/external-identities",
|
||||
"外部身份列表",
|
||||
ExternalIdentity,
|
||||
{ mediaEntityId: Id.optional(), provider: z.string().optional(), status: IdentityStatus.optional() },
|
||||
TI,
|
||||
),
|
||||
get: get("/external-identities/{id}", "外部身份详情", ExternalIdentity, TI),
|
||||
create: write(
|
||||
"post",
|
||||
"/external-identities",
|
||||
"organize.execute",
|
||||
"新增外部身份(accepted 等同人工纠错)",
|
||||
ExternalIdentityCreate,
|
||||
ExternalIdentity,
|
||||
TI,
|
||||
false,
|
||||
),
|
||||
update: write(
|
||||
"patch",
|
||||
"/external-identities/{id}",
|
||||
"organize.execute",
|
||||
"修订外部身份(乐观并发)",
|
||||
ExternalIdentityUpdate,
|
||||
ExternalIdentity,
|
||||
TI,
|
||||
),
|
||||
revisions: get(
|
||||
"/external-identities/{id}/revisions",
|
||||
"身份修订历史",
|
||||
z.object({ items: z.array(IdentityRevision) }),
|
||||
TI,
|
||||
),
|
||||
},
|
||||
decisions: {
|
||||
list: list(
|
||||
"/decisions",
|
||||
"待处理事项(缺省只列未关闭)",
|
||||
Decision,
|
||||
{ status: DecisionStatus.optional(), kind: DecisionKind.optional() },
|
||||
TD,
|
||||
),
|
||||
get: get("/decisions/{id}", "待处理事项详情", Decision, TD),
|
||||
resolve: write(
|
||||
"post",
|
||||
"/decisions/{id}/resolve",
|
||||
"organize.execute",
|
||||
"处理待处理事项",
|
||||
DecisionResolveInput,
|
||||
Decision,
|
||||
TD,
|
||||
),
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user