Files
UHD_Media_Tool/packages/contracts/src/api.ts
T
rootandClaude Fable 5.1 cc8b3a7df4 feat(db,contracts): full v0.1 data model migrations and shared zod contracts (W1-1)
- migrations 0002-0012 split by domain (foundation/instances/content/identity/
  sources/assets/policy/execution/agent/ops/organization): 41 tables, uuid pk,
  created_at/updated_at + touch trigger, text+CHECK status sets, pgcrypto/pg_trgm
- key constraints: source_release(site_id, site_release_id), observation(feed_id, guid),
  external_identity accepted partial unique, download_task(instance_id, torrent_hash),
  one active acquisition_plan per slot, one active path_binding per entity/season,
  asset_location root+path uniqueness, organization_record change_seq/change_xid cursor
- secret table (ciphertext/nonce/kdf) + secret_ref columns; outbox dispatch fields (ADR-002)
- src/types.ts generated from the migrated schema (scripts/gen-types.ts), drift-checked in tests;
  int8 -> number, date -> string parsers; migrator accepts migrationTableSchema
- contracts: Row/Create/Update schemas per domain, public organization-record API
  (changeSeq cursor), GlobalSettings, CapabilityDescriptor + 14 capability IO schemas,
  typed SSE events, Api endpoint table
- tests: migration up/down round trip on real PG in an isolated schema, constraint checks,
  contracts<->db column/enum sync, positive/negative parse for every exported schema

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:11:37 +08:00

784 lines
21 KiB
TypeScript

import { z } from "zod";
import * as agent from "./agent";
import * as assets from "./assets";
import { CapabilityDescriptor, KnownCapabilityId } from "./capability";
import { type ApiScope, CursorQuery, cursorPage, Id, IdParam } from "./common";
import * as content from "./content";
import * as execution from "./execution";
import * as identity from "./identity";
import * as instances from "./instances";
import * as ops from "./ops";
import * as organization from "./organization";
import * as policy from "./policy";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings";
import * as sources from "./sources";
// 所有 REST 端点的路径常量与请求/响应 schema 汇总。
// path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用);
// web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。
export const API_PREFIX = "/api/v1";
export type HttpMethod = "get" | "post" | "patch" | "put" | "delete";
export interface EndpointDef {
method: HttpMethod;
path: string;
scope: ApiScope;
summary: string;
params?: z.ZodObject;
query?: z.ZodType;
body?: z.ZodType;
response: z.ZodType;
}
const ep = <E extends EndpointDef>(e: E): E => e;
/** 列表查询:游标分页 + 资源自有过滤 */
const listQuery = <S extends z.ZodRawShape>(filters: S) => CursorQuery.extend(filters);
/** 删除/无内容动作的响应 */
export const OkResult = z.object({ ok: z.literal(true) });
interface CrudSchemas {
row: z.ZodType;
create?: z.ZodType;
update?: z.ZodType;
filters?: z.ZodRawShape;
}
/** 标准资源端点:list / get / create / update / remove(按给出的 schema 决定是否生成) */
function crud<const B extends string>(
base: B,
name: string,
s: CrudSchemas,
scopes: { read: ApiScope; write: ApiScope },
) {
const item = `${base}/{id}` as const;
return {
list: ep({
method: "get",
path: base,
scope: scopes.read,
summary: `${name}列表`,
query: listQuery(s.filters ?? {}),
response: cursorPage(s.row),
}),
get: ep({
method: "get",
path: item,
scope: scopes.read,
summary: `${name}详情`,
params: IdParam,
response: s.row,
}),
...(s.create
? {
create: ep({
method: "post",
path: base,
scope: scopes.write,
summary: `新建${name}`,
body: s.create,
response: s.row,
}),
}
: {}),
...(s.update
? {
update: ep({
method: "patch",
path: item,
scope: scopes.write,
summary: `修改${name}`,
params: IdParam,
body: s.update,
response: s.row,
}),
}
: {}),
};
}
const R = { read: "read", write: "admin" } as const;
const del = (path: string, summary: string) =>
ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult });
export const Api = {
// ── 内容 / 身份 ──
mediaEntities: {
...crud(
"/media-entities",
"作品",
{
row: content.MediaEntity,
create: content.MediaEntityCreate,
update: content.MediaEntityUpdate,
filters: {
kind: content.MediaKind.optional(),
status: content.MediaEntityStatus.optional(),
q: z.string().max(200).optional(),
},
},
R,
),
merge: ep({
method: "post",
path: "/media-entities/{id}/merge",
scope: "admin",
summary: "合并重复作品(保留旧 ID 跳转)",
params: IdParam,
body: z.object({ intoId: Id, reason: z.string().max(2000) }),
response: content.MediaEntity,
}),
},
contentUnits: crud(
"/content-units",
"内容单元",
{
row: content.ContentUnit,
create: content.ContentUnitCreate,
update: content.ContentUnitUpdate,
filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() },
},
R,
),
titleAliases: {
...crud(
"/title-aliases",
"别名",
{
row: content.TitleAlias,
create: content.TitleAliasCreate,
update: content.TitleAliasUpdate,
filters: { mediaEntityId: Id.optional(), q: z.string().max(200).optional() },
},
R,
),
remove: del("/title-aliases/{id}", "删除别名"),
},
externalIdentities: {
...crud(
"/external-identities",
"外部身份",
{
row: identity.ExternalIdentity,
create: identity.ExternalIdentityCreate,
update: identity.ExternalIdentityUpdate,
filters: {
mediaEntityId: Id.optional(),
provider: z.string().optional(),
status: identity.IdentityStatus.optional(),
},
},
R,
),
revisions: ep({
method: "get",
path: "/external-identities/{id}/revisions",
scope: "read",
summary: "身份修订历史",
params: IdParam,
response: z.object({ items: z.array(identity.IdentityRevision) }),
}),
},
// ── 文件 ──
mediaAssets: crud(
"/media-assets",
"资产",
{
row: assets.MediaAsset,
update: assets.MediaAssetUpdate,
filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() },
},
R,
),
assetUnitCoverage: crud(
"/asset-unit-coverage",
"资产覆盖",
{
row: assets.AssetUnitCoverage,
create: assets.AssetUnitCoverageCreate,
update: assets.AssetUnitCoverageUpdate,
filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() },
},
R,
),
assetLocations: crud(
"/asset-locations",
"资产位置",
{
row: assets.AssetLocation,
update: assets.AssetLocationUpdate,
filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() },
},
R,
),
pathBindings: crud(
"/path-bindings",
"路径绑定",
{
row: assets.PathBinding,
create: assets.PathBindingCreate,
update: assets.PathBindingUpdate,
filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() },
},
R,
),
// ── 实例 ──
libraryRoots: crud(
"/library-roots",
"媒体库根",
{
row: instances.LibraryRoot,
create: instances.LibraryRootCreate,
update: instances.LibraryRootUpdate,
},
R,
),
downloaderInstances: {
...crud(
"/downloader-instances",
"下载器实例",
{
row: instances.DownloaderInstance,
create: instances.DownloaderInstanceCreate,
update: instances.DownloaderInstanceUpdate,
},
R,
),
test: ep({
method: "post",
path: "/downloader-instances/{id}/test",
scope: "admin",
summary: "测试下载器连接",
params: IdParam,
response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }),
}),
},
mediaServerInstances: crud(
"/media-server-instances",
"媒体服务器实例",
{
row: instances.MediaServerInstance,
create: instances.MediaServerInstanceCreate,
update: instances.MediaServerInstanceUpdate,
},
R,
),
pathMappings: {
...crud(
"/path-mappings",
"路径映射",
{
row: instances.PathMapping,
create: instances.PathMappingCreate,
update: instances.PathMappingUpdate,
},
R,
),
verify: ep({
method: "post",
path: "/path-mappings/{id}/verify",
scope: "admin",
summary: "校验路径映射可读写与挂载健康",
params: IdParam,
response: instances.PathMapping,
}),
remove: del("/path-mappings/{id}", "删除路径映射"),
},
// ── 来源 ──
sites: crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
siteAccounts: crud(
"/site-accounts",
"站点账号",
{
row: sources.SiteAccount,
create: sources.SiteAccountCreate,
update: sources.SiteAccountUpdate,
filters: { siteId: Id.optional() },
},
R,
),
feedSources: {
...crud(
"/feed-sources",
"Feed",
{
row: sources.FeedSource,
create: sources.FeedSourceCreate,
update: sources.FeedSourceUpdate,
filters: { siteId: Id.optional(), health: sources.FeedHealth.optional() },
},
R,
),
fetchNow: ep({
method: "post",
path: "/feed-sources/{id}/fetch",
scope: "admin",
summary: "立即抓取(仍受站点限流)",
params: IdParam,
response: z.object({ taskId: Id }),
}),
},
sourceReleases: crud(
"/source-releases",
"站点资源",
{
row: sources.SourceRelease,
filters: {
siteId: Id.optional(),
mediaEntityId: Id.optional(),
resolutionStatus: sources.ResolutionStatus.optional(),
q: z.string().max(200).optional(),
},
},
R,
),
observations: crud(
"/observations",
"观察",
{
row: sources.Observation,
filters: { feedId: Id.optional(), status: sources.ObservationStatus.optional() },
},
R,
),
// ── 规则 ──
subscriptions: crud(
"/subscriptions",
"订阅",
{
row: policy.Subscription,
create: policy.SubscriptionCreate,
update: policy.SubscriptionUpdate,
filters: { status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional() },
},
R,
),
policyRevisions: {
...crud(
"/policy-revisions",
"策略版本",
{
row: policy.PolicyRevision,
create: policy.PolicyRevisionCreate,
update: policy.PolicyRevisionUpdate,
filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() },
},
R,
),
preview: ep({
method: "post",
path: "/policy-revisions/{id}/preview",
scope: "read",
summary: "历史回放预览",
params: IdParam,
response: policy.PolicyRevision,
}),
publish: ep({
method: "post",
path: "/policy-revisions/{id}/publish",
scope: "admin",
summary: "发布策略版本",
params: IdParam,
body: z.object({ expectedRevision: z.number().int().min(1) }),
response: policy.PolicyRevision,
}),
rollback: ep({
method: "post",
path: "/policy-revisions/{id}/rollback",
scope: "admin",
summary: "回滚到该版本",
params: IdParam,
response: policy.PolicyRevision,
}),
},
acquisitionSlots: crud(
"/acquisition-slots",
"收录目标",
{
row: policy.AcquisitionSlot,
create: policy.AcquisitionSlotCreate,
update: policy.AcquisitionSlotUpdate,
filters: { mediaEntityId: Id.optional(), status: policy.SlotStatus.optional() },
},
R,
),
preferenceTags: {
...crud(
"/preference-tags",
"偏好标签",
{
row: policy.PreferenceTag,
create: policy.PreferenceTagCreate,
update: policy.PreferenceTagUpdate,
filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() },
},
R,
),
/** 删除 = 标记 deleted + 写抑制记录 */
remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"),
},
preferenceSuppressions: crud(
"/preference-suppressions",
"偏好抑制",
{
row: policy.PreferenceSuppression,
create: policy.PreferenceSuppressionCreate,
update: policy.PreferenceSuppressionUpdate,
filters: { active: z.enum(["true", "false"]).optional() },
},
R,
),
// ── 执行 ──
tasks: {
...crud(
"/tasks",
"任务",
{
row: execution.Task,
filters: {
status: execution.TaskStatus.optional(),
kind: execution.TaskKind.optional(),
subjectId: Id.optional(),
},
},
R,
),
cancel: ep({
method: "post",
path: "/tasks/{id}/cancel",
scope: "organize.execute",
summary: "取消任务(停止未来步骤)",
params: IdParam,
body: execution.TaskCancelRequest,
response: execution.Task,
}),
retry: ep({
method: "post",
path: "/tasks/{id}/retry",
scope: "organize.execute",
summary: "重试失败任务",
params: IdParam,
response: execution.Task,
}),
operations: ep({
method: "get",
path: "/tasks/{id}/operations",
scope: "read",
summary: "任务的操作账本(业务时间线)",
params: IdParam,
response: z.object({ items: z.array(execution.AssetOperation) }),
}),
},
acquisitionPlans: crud(
"/acquisition-plans",
"下载计划",
{
row: execution.AcquisitionPlan,
filters: { slotId: Id.optional(), status: execution.PlanStatus.optional() },
},
R,
),
downloadTasks: {
...crud(
"/download-tasks",
"下载任务",
{
row: execution.DownloadTask,
filters: {
instanceId: Id.optional(),
status: execution.DownloadStatus.optional(),
managed: z.enum(["true", "false"]).optional(),
},
},
R,
),
pause: ep({
method: "post",
path: "/download-tasks/{id}/pause",
scope: "organize.execute",
summary: "暂停(记为用户暂停,阈值恢复不会自动恢复)",
params: IdParam,
response: execution.DownloadTask,
}),
resume: ep({
method: "post",
path: "/download-tasks/{id}/resume",
scope: "organize.execute",
summary: "恢复下载",
params: IdParam,
response: execution.DownloadTask,
}),
},
// ── 人工 / AI ──
decisions: {
...crud(
"/decisions",
"待处理事项",
{
row: agent.Decision,
filters: { status: agent.DecisionStatus.optional(), kind: agent.DecisionKind.optional() },
},
R,
),
resolve: ep({
method: "post",
path: "/decisions/{id}/resolve",
scope: "organize.execute",
summary: "处理待处理事项",
params: IdParam,
body: agent.DecisionResolveInput,
response: agent.Decision,
}),
},
approvals: crud(
"/approvals",
"审批",
{
row: agent.Approval,
update: agent.ApprovalUpdate,
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
automationGrants: crud(
"/automation-grants",
"持续授权",
{
row: agent.AutomationGrant,
create: agent.AutomationGrantCreate,
update: agent.AutomationGrantUpdate,
filters: { status: agent.GrantStatus.optional() },
},
R,
),
agentRuns: {
...crud(
"/agent-runs",
"Agent 运行",
{
row: agent.AgentRun,
create: agent.AgentRunCreate,
filters: { status: agent.AgentRunStatus.optional(), taskId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
cancel: ep({
method: "post",
path: "/agent-runs/{id}/cancel",
scope: "organize.execute",
summary: "取消 Agent 运行",
params: IdParam,
response: agent.AgentRun,
}),
message: ep({
method: "post",
path: "/agent-runs/{id}/messages",
scope: "organize.execute",
summary: "向运行中的会话追加用户消息(纠正/补充)",
params: IdParam,
body: z.object({ text: z.string().min(1).max(8000) }),
response: agent.AgentRun,
}),
},
artifacts: crud(
"/artifacts",
"产物",
{
row: agent.Artifact,
filters: { agentRunId: Id.optional(), taskId: Id.optional(), kind: agent.ArtifactKind.optional() },
},
R,
),
// ── 运维 ──
reconcileRuns: crud(
"/reconcile-runs",
"校验运行",
{
row: ops.ReconcileRun,
create: ops.ReconcileRunCreate,
filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() },
},
R,
),
serverMappings: crud(
"/server-mappings",
"库存映射",
{
row: ops.ServerMapping,
update: ops.ServerMappingUpdate,
filters: {
mediaServerInstanceId: Id.optional(),
mediaEntityId: Id.optional(),
matchStatus: ops.ServerMatchStatus.optional(),
},
},
R,
),
apiKeys: {
list: ep({
method: "get",
path: "/api-keys",
scope: "admin",
summary: "API Key 列表",
query: CursorQuery,
response: cursorPage(ops.ApiKey),
}),
create: ep({
method: "post",
path: "/api-keys",
scope: "admin",
summary: "新建 API Key(明文只返回一次)",
body: ops.ApiKeyCreate,
response: ops.ApiKeyCreated,
}),
update: ep({
method: "patch",
path: "/api-keys/{id}",
scope: "admin",
summary: "修改/撤销 API Key",
params: IdParam,
body: ops.ApiKeyUpdate,
response: ops.ApiKey,
}),
},
auditEvents: {
list: ep({
method: "get",
path: "/audit-events",
scope: "admin",
summary: "审计事件",
query: listQuery({
targetType: z.string().optional(),
targetId: z.string().optional(),
action: z.string().optional(),
}),
response: cursorPage(ops.AuditEvent),
}),
},
secrets: {
list: ep({
method: "get",
path: "/secrets",
scope: "admin",
summary: "凭证元数据列表",
query: CursorQuery,
response: cursorPage(ops.Secret),
}),
create: ep({
method: "post",
path: "/secrets",
scope: "admin",
summary: "新建凭证(只写)",
body: ops.SecretCreate,
response: ops.Secret,
}),
update: ep({
method: "patch",
path: "/secrets/{id}",
scope: "admin",
summary: "轮换凭证",
params: IdParam,
body: ops.SecretUpdate,
response: ops.Secret,
}),
},
// ── 整理(公开合同) ──
organization: {
list: ep({
method: "get",
path: "/organization-records",
scope: "organize.read",
summary: "整理记录列表",
query: organization.OrganizationListQuery,
response: cursorPage(organization.PublicOrganizationRecord),
}),
get: ep({
method: "get",
path: "/organization-records/{id}",
scope: "organize.read",
summary: "整理记录详情(含逐文件结果)",
params: IdParam,
response: organization.PublicOrganizationRecord,
}),
changes: ep({
method: "get",
path: "/organization-records/changes",
scope: "organize.read",
summary: "按 changeSeq 增量读取(含既有记录状态更新)",
query: organization.OrganizationChangesQuery,
response: organization.OrganizationChangesResponse,
}),
create: ep({
method: "post",
path: "/organization-records",
scope: "organize.execute",
summary: "手动发起整理",
body: organization.OrganizeRequest,
response: organization.OrganizeAccepted,
}),
},
// ── 能力 ──
capabilities: {
list: ep({
method: "get",
path: "/capabilities",
scope: "read",
summary: "能力注册中心描述",
response: z.object({ items: z.array(CapabilityDescriptor) }),
}),
invoke: ep({
method: "post",
path: "/capabilities/{capabilityId}/invoke",
scope: "read",
summary: "调用能力(实际所需 scope 见描述符;服务端按能力再次鉴权)",
params: z.object({ capabilityId: KnownCapabilityId }),
body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }),
response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }),
}),
},
// ── 设置 ──
settings: {
get: ep({ method: "get", path: "/settings", scope: "read", summary: "全局设置", response: GlobalSettings }),
update: ep({
method: "patch",
path: "/settings",
scope: "admin",
summary: "修改全局设置",
body: GlobalSettingsUpdate,
response: GlobalSettings,
}),
},
} as const;
/** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */
export function buildPath(path: string, params: Record<string, string> = {}): string {
const filled = path.replace(/\{(\w+)\}/g, (_, k: string) => {
const v = params[k];
if (v === undefined) throw new Error(`缺少路径参数 ${k}`);
return encodeURIComponent(v);
});
return `${API_PREFIX}${filled}`;
}
/** 扁平列出全部端点(生成 OpenAPI、权限表与测试用) */
export function listEndpoints(): EndpointDef[] {
return Object.values(Api).flatMap((group) => Object.values(group) as EndpointDef[]);
}
export type EndpointBody<E extends EndpointDef> = E["body"] extends z.ZodType ? z.input<E["body"]> : never;
export type EndpointResponse<E extends EndpointDef> = z.infer<E["response"]>;