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: E): E => e; /** 列表查询:游标分页 + 资源自有过滤 */ const listQuery = (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( 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 { 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["body"] extends z.ZodType ? z.input : never; export type EndpointResponse = z.infer;