import { z } from "zod"; import * as agent from "./agent.ts"; import * as assets from "./assets.ts"; import * as auth from "./auth.ts"; import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts"; import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts"; import * as content from "./content.ts"; import * as events from "./events.ts"; import * as execution from "./execution.ts"; import * as files from "./files.ts"; import * as identity from "./identity.ts"; import * as instances from "./instances.ts"; import * as ops from "./ops.ts"; import * as organization from "./organization.ts"; import * as policy from "./policy.ts"; import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts"; import * as sources from "./sources.ts"; // 所有 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"; /** * 鉴权方式(不写即按 scope 校验,cookie 会话与 API Key 同样适用): * - public:免鉴权(必须与服务端 http/auth.ts 的 PUBLIC_AUTH_PATHS 一致,测试断言); * - session:只接受控制台管理员 cookie 会话,API Key(含 admin scope)一律 403。 */ export type EndpointAuth = "public" | "session"; export interface EndpointDef { method: HttpMethod; path: string; scope: ApiScope; auth?: EndpointAuth; 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 }); /** 实例连通性测试 */ const instanceTest = (path: string, summary: string) => ep({ method: "post", path, scope: "admin", summary, params: IdParam, response: instances.InstanceTestResult }); /** 入队后台任务的受理结果(taskId = 引擎 run id) */ export const TaskAccepted = z.object({ taskId: Id }); export type TaskAccepted = z.infer; /** 人工指定来源资源身份的结果(待识别池 assign / source-releases identity) */ export const ReleaseIdentityResult = z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) }); export type ReleaseIdentityResult = z.infer; /** 身份修订历史 */ export const IdentityRevisionList = z.object({ items: z.array(identity.IdentityRevision) }); export type IdentityRevisionList = z.infer; /** 字段覆盖/取消覆盖的结果 */ const FieldOverrideResult = z.object({ mediaEntityId: Id, changedFields: z.array(z.string()), lockedFields: z.array(z.string()), }); const FieldParam = z.object({ id: Id, field: content.EntityMetadataField }); /** 引擎 run id 不是 uuid(openworkflow 自有 id) */ const EngineRunIdParam = z.object({ id: z.string().min(1).max(200) }); /** 待处理事项聚合分组 */ export const DecisionSummaryGroup = z.object({ kind: agent.DecisionKind, mediaEntityId: Id.nullable(), mediaEntityTitle: z.string().nullable(), count: z.number().int(), maxSeverity: z.enum(["info", "warning", "critical"]), latestAt: z.string(), }); export type DecisionSummaryGroup = z.infer; export const DecisionSummary = z.object({ total: z.number().int(), groups: z.array(DecisionSummaryGroup) }); export type DecisionSummary = z.infer; 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, }), detail: ep({ method: "get", path: "/media-entities/{id}/detail", scope: "read", summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)", params: IdParam, response: content.MediaEntityDetail, }), setIdentity: ep({ method: "post", path: "/media-entities/{id}/identities", scope: "organize.execute", summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁", params: IdParam, body: identity.EntityIdentitySet, response: z.object({ mediaEntityId: Id, changed: z.boolean(), identityId: Id.optional(), retired: z.array(Id).optional(), }), }), refresh: ep({ method: "post", path: "/media-entities/{id}/refresh", scope: "organize.execute", summary: "刷新元数据(字段级;用户锁优先,不动路径)", params: IdParam, response: TaskAccepted, }), setField: ep({ method: "put", path: "/media-entities/{id}/fields/{field}", scope: "organize.execute", summary: "覆盖作品字段并锁定(自动刷新不再改写)", params: FieldParam, body: content.FieldOverrideInput, response: FieldOverrideResult, }), clearField: ep({ method: "delete", path: "/media-entities/{id}/fields/{field}", scope: "organize.execute", summary: "取消字段覆盖与锁定", params: FieldParam, response: FieldOverrideResult, }), }, contentUnits: crud( "/content-units", "内容单元", { row: content.ContentUnit, update: content.ContentUnitUpdate, filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() }, }, R, ), titleAliases: { ...crud( "/title-aliases", "别名", { row: content.TitleAlias, create: content.TitleAliasCreate, 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: IdentityRevisionList, }), }, // ── 文件(只读;写入只经整理/对账流程,不开放直接改) ── mediaAssets: crud( "/media-assets", "资产", { row: assets.MediaAsset, filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() }, }, R, ), assetUnitCoverage: crud( "/asset-unit-coverage", "资产覆盖", { row: assets.AssetUnitCoverage, filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() }, }, R, ), assetLocations: crud( "/asset-locations", "资产位置", { row: assets.AssetLocation, filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() }, }, R, ), pathBindings: crud( "/path-bindings", "路径绑定", { row: assets.PathBinding, filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() }, }, R, ), // ── 实例 ── libraryRoots: { ...crud( "/library-roots", "媒体库根", { row: instances.LibraryRoot, create: instances.LibraryRootCreate, update: instances.LibraryRootUpdate, }, R, ), test: instanceTest("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)"), remove: del("/library-roots/{id}", "删除媒体库根"), }, 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() }), }), remove: del("/downloader-instances/{id}", "删除下载器实例"), }, mediaServerInstances: { ...crud( "/media-server-instances", "媒体服务器实例", { row: instances.MediaServerInstance, create: instances.MediaServerInstanceCreate, update: instances.MediaServerInstanceUpdate, }, R, ), test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"), remove: del("/media-server-instances/{id}", "删除媒体服务器实例"), }, 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), enable: ep({ method: "post", path: "/sites/{id}/enable", scope: "admin", summary: "开启站点", params: IdParam, response: sources.Site, }), disable: ep({ method: "post", path: "/sites/{id}/disable", scope: "admin", summary: "关闭站点(停止网络访问与新候选,历史资源仍可查)", params: IdParam, response: sources.Site, }), snapshot: ep({ method: "post", path: "/sites/{id}/snapshot", scope: "admin", summary: "立即同步站点快照(受站点限流)", params: IdParam, body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }), response: TaskAccepted, }), snapshots: ep({ method: "get", path: "/sites/{id}/snapshots", scope: "read", summary: "站点快照/补查记录(覆盖范围与新鲜度)", params: IdParam, query: CursorQuery, response: cursorPage(sources.SiteSnapshot), }), /** 兼容别名:规范接口是 /site-accounts 资源 CRUD;这里按 (site,label) 新建或更新 site_account 的凭证 */ credentials: ep({ method: "put", path: "/sites/{id}/credentials", scope: "admin", summary: "兼容别名:按 (站点, label) 新建或更新 site_account 凭证(→ secret_ref,不回显);新客户端请用 POST/PATCH /site-accounts", params: IdParam, body: instances.SiteCredentialsPut, response: instances.SiteCredentialsResult, }), }, siteDefinitions: { list: ep({ method: "get", path: "/site-definitions", scope: "read", summary: "随产品发布的站点适配器定义(含是否已登记)", response: z.object({ items: z.array(sources.SiteDefinitionView) }), }), }, /** 站点工作台(整理视图):库存叠加走 inventory.overlay */ siteWorkbench: { releases: ep({ method: "get", path: "/sites/{id}/releases", scope: "read", summary: "站点资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)", params: IdParam, query: sources.WorkbenchReleaseQuery, response: sources.WorkbenchReleasePage, }), release: ep({ method: "get", path: "/sites/{id}/releases/{rid}", scope: "read", summary: "站点资源详情(refresh=true 时实时复核详情;失败保留既有数据)", params: z.object({ id: Id, rid: Id }), query: z.object({ refresh: z.enum(["true", "false"]).optional() }), response: sources.WorkbenchReleaseDetail, }), }, 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 }), }), enable: ep({ method: "post", path: "/feed-sources/{id}/enable", scope: "admin", summary: "启用 Feed", params: IdParam, response: sources.FeedSource, }), disable: ep({ method: "post", path: "/feed-sources/{id}/disable", scope: "admin", summary: "停用 Feed", params: IdParam, response: sources.FeedSource, }), reset: ep({ method: "post", path: "/feed-sources/{id}/reset", scope: "admin", summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)", params: IdParam, response: sources.FeedSource, }), }, 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, ), assignIdentity: ep({ method: "post", path: "/source-releases/{id}/identity", scope: "organize.execute", summary: "人工指定来源资源的作品/内容单元(加人工锁,自动识别不再改写)", params: IdParam, body: identity.ReleaseIdentityAssign, response: ReleaseIdentityResult, }), unlock: ep({ method: "post", path: "/source-releases/{id}/unlock", scope: "organize.execute", summary: "解除来源资源的人工锁并重新识别", params: IdParam, response: TaskAccepted, }), }, 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(), mediaKind: policy.SubscriptionMediaKind.optional(), /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ q: z.string().max(200).optional(), }, }, R, ), search: ep({ method: "post", path: "/subscriptions/{id}/search", scope: "admin", summary: "手动主动搜索(按标题/别名在已开启站点搜索,结果走同一识别评估链)", params: IdParam, response: z.object({ runId: z.string(), deduplicated: z.boolean() }), }), progress: ep({ method: "get", path: "/subscriptions/{id}/progress", scope: "read", summary: "订阅进度:季总集数账本、每集状态(已覆盖/在途/缺失/疑似缺集)、Slot 在途与延后", params: IdParam, response: policy.SubscriptionProgress, }), setEpisodeTotal: ep({ method: "post", path: "/subscriptions/{id}/episode-total", scope: "admin", summary: "手动设置季总集数(只升;locked 锁定后刷新不再改写)", params: IdParam, body: policy.EpisodeTotalSet, response: policy.SubscriptionProgress, }), }, subscriptionHistory: crud( "/subscription-history", "订阅完成历史", { row: policy.SubscriptionHistory, filters: { mediaEntityId: Id.optional(), subscriptionId: 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(), subscriptionId: Id.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}", "删除偏好标签(留抑制记录)"), revisions: ep({ method: "get", path: "/preference-tags/{id}/revisions", scope: "read", summary: "偏好标签修订历史(新→旧;取自审计事件)", params: IdParam, response: policy.PreferenceTagRevisionList, }), }, 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, ), create: ep({ method: "post", path: "/acquisition-plans", scope: "organize.execute", summary: "手动提交下载:与自动收录同一流程(download.plan → download.submit,含硬条件/预算/缓存阈值/授权检查);未生成计划时返回拒绝原因", body: execution.ManualAcquisitionRequest, response: execution.ManualAcquisitionResult, }), }, 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, }), summary: ep({ method: "get", path: "/decisions/summary", scope: "read", summary: "未关闭待处理事项按作品与问题类型聚合", query: z.object({ status: agent.DecisionStatus.optional() }), response: DecisionSummary, }), }, approvals: { ...crud( "/approvals", "审批", { row: agent.Approval, filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() }, }, R, ), // 审批的申请与决定只在控制台管理员会话里做:API Key(哪怕持 organize.execute)不能给自己的计划开审批(验收 S1) update: ep({ method: "patch", path: "/approvals/{id}", scope: "admin", auth: "session", summary: "批准/拒绝/撤销单次审批(只接受控制台管理员会话)", params: IdParam, body: agent.ApprovalUpdate, response: agent.Approval, }), create: ep({ method: "post", path: "/approvals", scope: "admin", auth: "session", summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期;只接受控制台管理员会话)", body: agent.ApprovalRequest, response: agent.Approval, }), }, automationGrants: crud( "/automation-grants", "持续授权", { row: agent.AutomationGrant, create: agent.AutomationGrantCreate, update: agent.AutomationGrantUpdate, filters: { status: agent.GrantStatus.optional() }, }, R, ), /** Agent 会话(W2-D):建 run 只入队持久任务 runAgent;增量经全局 SSE(agent.*),断线按 runSeq 用 afterSeq 补读 */ agentRuns: { list: ep({ method: "get", path: "/agent/runs", scope: "read", summary: "Agent 运行列表(新→旧)", query: agent.AgentRunListQuery, response: cursorPage(agent.AgentRun), }), get: ep({ method: "get", path: "/agent/runs/{id}", scope: "read", summary: "Agent 运行详情:事件按 seq 分页、追加消息、服务端核查的目标汇报", params: IdParam, query: agent.AgentRunDetailQuery, response: agent.AgentRunDetail, }), create: ep({ method: "post", path: "/agent/runs", scope: "organize.execute", summary: "委托目标:建 agent_run 并入队 runAgent(execute | plan_only)", body: agent.AgentRunStartInput, response: agent.AgentRun, }), message: ep({ method: "post", path: "/agent/runs/{id}/messages", scope: "organize.execute", summary: "追加用户消息(纠正/补充);运行已结束时唤醒续跑", params: IdParam, body: agent.AgentRunMessageInput, response: agent.AgentRun, }), abort: ep({ method: "post", path: "/agent/runs/{id}/abort", scope: "organize.execute", summary: "中止运行(停止未来步骤,不撤销已完成的外部动作)", params: IdParam, body: agent.AgentRunAbortInput, 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, filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() }, }, R, ), serverMappings: crud( "/server-mappings", "库存映射", { row: ops.ServerMapping, 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, }), revoke: del("/api-keys/{id}", "撤销 API Key"), }, auditEvents: { list: ep({ method: "get", path: "/audit-events", scope: "admin", summary: "审计事件(按 actor/action/target 过滤,新→旧)", query: ops.AuditEventQuery, 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, }), remove: del("/secrets/{id}", "删除凭证(被引用时 409)"), }, // ── 整理(公开合同) ── 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, }), }, // ── 文件管理(docs/plan/06-file-manager-spec.md;只浏览已登记根,写操作两段式 dryRun → planId) ── files: { roots: ep({ method: "get", path: "/files/roots", scope: "organize.read", summary: "可浏览的根(库根 + 下载根),含实时健康与空间", response: files.FileRootList, }), list: ep({ method: "get", path: "/files", scope: "organize.read", summary: "列目录(分页/排序/过滤/截断,行内业务状态);根离线 503", query: files.FilesListQuery, response: files.FilesListResponse, }), stat: ep({ method: "get", path: "/files/stat", scope: "organize.read", summary: "单项详情:realpath/inode/nlink/sidecars/路径绑定", query: files.FileStatQuery, response: files.FileStat, }), mkdir: ep({ method: "post", path: "/files/mkdir", scope: "organize.execute", summary: "新建目录(非递归,已存在 409,仅 writable 根)", body: files.FilesMkdirRequest, response: files.FilesMkdirResult, }), rename: ep({ method: "post", path: "/files/rename", scope: "organize.execute", summary: "同目录改名(不覆盖;同步 asset_location 与 path_binding 修订;做种中 409)", body: files.FilesRenameRequest, response: files.FilesRenameResult, }), move: ep({ method: "post", path: "/files/move", scope: "organize.execute", summary: "移动/复制到目录:dryRun 返回 planId,执行回传 planId(源变更 409 plan_stale)", body: files.FilesMoveRequest, response: files.FilesTransferResult, }), copy: ep({ method: "post", path: "/files/copy", scope: "organize.execute", summary: "复制到目录(同 /files/move mode=copy)", body: files.FilesCopyRequest, response: files.FilesTransferResult, }), organize: ep({ method: "post", path: "/files/organize", scope: "organize.execute", summary: "按作品分组整理入库(每组走 planImport,与 /organization-records 同实现)", body: files.FilesOrganizeRequest, response: files.FilesOrganizeResult, }), delete: ep({ method: "post", path: "/files/delete", scope: "organize.execute", summary: "删除(默认进回收站;引用检查:做种/资产/绑定;permanent 仅 admin 会话)", body: files.FilesDeleteRequest, response: files.FilesDeleteResult, }), trash: ep({ method: "get", path: "/files/trash", scope: "organize.read", summary: "回收站列表", query: files.FilesTrashQuery, response: files.FilesTrashList, }), trashRestore: ep({ method: "post", path: "/files/trash/restore", scope: "organize.execute", summary: "从回收站还原(原位被占的项失败 conflict)", body: files.FilesTrashRestoreRequest, response: files.FilesTrashRestoreResult, }), trashPurge: ep({ method: "post", path: "/files/trash/purge", scope: "admin", auth: "session", summary: "清空回收站(缺省只清过期项;仅管理员控制台会话,API Key 与 DEV key 一律 403)", body: files.FilesTrashPurgeRequest, response: files.FilesTrashPurgeResult, }), identify: ep({ method: "post", path: "/files/identify", scope: "organize.read", summary: "识别(只读):解析 + 本地/TMDB 召回前 5 候选,不落库", body: files.FilesIdentifyRequest, response: files.FilesIdentifyResult, }), scrape: ep({ method: "post", path: "/files/scrape", scope: "organize.execute", summary: "刮削已登记作品(字段级刷新,遵守字段锁;NFO 投影 W5)", body: files.FilesScrapeRequest, response: files.FilesScrapeResult, }), probe: ep({ method: "post", path: "/files/probe", scope: "organize.execute", summary: "ffprobe 摘要(≤20 项;已登记资产写回 media_info,所以要 organize.execute)", body: files.FilesProbeRequest, response: files.FilesProbeResult, }), download: ep({ method: "get", path: "/files/download", scope: "admin", auth: "session", summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;仅管理员控制台会话;审计)", query: files.FilesDownloadQuery, response: files.FilesDownloadResponse, }), records: ep({ method: "get", path: "/files/records", scope: "organize.read", summary: "按整理记录 id 或 planId 回查整理记录(整理页预填)", query: files.FilesRecordsQuery, response: files.FilesRecordsResult, }), }, // ── 能力 ── 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, }), }, // ── 登录门禁 ── /** /auth/setup、/auth/login、/auth/session 免鉴权;改密与 TOTP 只接受控制台 cookie 会话 */ auth: { setup: ep({ method: "post", path: "/auth/setup", scope: "read", auth: "public", summary: "首次初始化管理员", body: auth.AdminSetupRequest, response: auth.SessionInfo, }), login: ep({ method: "post", path: "/auth/login", scope: "read", auth: "public", summary: "管理员登录", body: auth.LoginRequest, response: auth.LoginResult, }), logout: ep({ method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult }), session: ep({ method: "get", path: "/auth/session", scope: "read", auth: "public", summary: "当前登录态(免鉴权)", response: auth.SessionInfo, }), password: ep({ method: "post", path: "/auth/password", scope: "admin", auth: "session", summary: "修改密码(吊销其它会话)", body: auth.PasswordChangeRequest, response: auth.PasswordChangeResult, }), totpStatus: ep({ method: "get", path: "/auth/totp", scope: "admin", auth: "session", summary: "TOTP 状态", response: auth.TotpStatus, }), totp: ep({ method: "post", path: "/auth/totp", scope: "admin", auth: "session", summary: "TOTP 绑定/启用/停用", body: auth.TotpAction, response: auth.TotpActionResult, }), }, // ── 事件 ── events: { list: ep({ method: "get", path: "/events", scope: "read", summary: "事件流:控制台用 SSE(text/event-stream,cookie 会话);API Key 调用者带 limit 取 JSON 补读页(after=上次 nextAfter)", query: events.EventReplayQuery, response: events.EventReplayPage, }), }, // ── 系统 ── system: { health: ep({ method: "get", path: "/health", scope: "read", summary: "服务健康状态(免鉴权)", response: HealthStatus, }), }, // ── 引擎面板(仅 admin) ── engine: { runs: ep({ method: "get", path: "/engine/runs", scope: "admin", summary: "引擎 runs(状态/名称/时间筛选,新→旧)", query: ops.EngineRunQuery, response: cursorPage(ops.EngineRun), }), run: ep({ method: "get", path: "/engine/runs/{id}", scope: "admin", summary: "run 详情(step attempts + signals)", params: EngineRunIdParam, response: ops.EngineRunDetail, }), stats: ep({ method: "get", path: "/engine/stats", scope: "admin", summary: "按状态计数 / 最老待处理 / 活跃 worker 快照", response: ops.EngineStats, }), cancel: ep({ method: "post", path: "/engine/runs/{id}/cancel", scope: "admin", summary: "取消 run", params: EngineRunIdParam, body: ops.EngineCancelRequest, response: ops.EngineCancelResult, }), retry: ep({ method: "post", path: "/engine/runs/{id}/retry", scope: "admin", summary: "按原任务名与输入重新入队(新幂等键 retry::<序号>)", params: EngineRunIdParam, response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }), }), }, // ── 待识别池 ── identityPool: { list: ep({ method: "get", path: "/identity-pool", scope: "read", summary: "待识别池(可按原始标题检索;候选不建正式作品)", query: identity.IdentityPoolQuery, response: cursorPage(identity.IdentityPoolItem), }), assign: ep({ method: "post", path: "/identity-pool/{id}/assign", scope: "organize.execute", summary: "为待识别资源手动指定候选 / 本地作品 / 外部身份", params: IdParam, body: identity.ReleaseIdentityAssign, response: ReleaseIdentityResult, }), dismiss: ep({ method: "post", path: "/identity-pool/{id}/dismiss", scope: "organize.execute", summary: "忽略待识别资源(不再自动复查)", params: IdParam, body: z.object({ note: z.string().max(2000).optional() }), response: identity.IdentityCandidate, }), retry: ep({ method: "post", path: "/identity-pool/{id}/retry", scope: "organize.execute", summary: "立即重新识别", params: IdParam, response: TaskAccepted, }), }, // ── 媒体库 / 库存 / 首页 ── library: { list: ep({ method: "get", path: "/library", scope: "read", summary: "媒体库列表(已登记资产或正在追踪的作品)", query: organization.LibraryListQuery, response: cursorPage(organization.LibraryItem), }), get: ep({ method: "get", path: "/library/{id}", scope: "read", summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果", params: IdParam, response: organization.LibraryDetail, }), reconcile: ep({ method: "post", path: "/library/reconcile", scope: "admin", summary: "立即做一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载)", body: z.object({ libraryRootId: z.uuid().optional() }), response: z.object({ runId: z.uuid(), stats: z.record(z.string(), z.unknown()) }), }), }, inventory: { overlay: ep({ method: "get", path: "/inventory", scope: "read", summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中", query: organization.InventoryQuery, response: organization.InventoryResponse, }), mediaServers: ep({ method: "get", path: "/inventory/media-servers", scope: "read", summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)", query: organization.MediaServerInventoryQuery, response: organization.MediaServerInventory, }), }, dashboard: { get: ep({ method: "get", path: "/dashboard", scope: "read", summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出", response: organization.Dashboard, }), }, downloadCache: { cleanup: ep({ method: "post", path: "/download-cache/cleanup", scope: "admin", summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)", body: z.object({ downloaderInstanceId: z.uuid().optional() }), response: z.object({ report: z.record(z.string(), z.unknown()) }), }), }, } 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; export * from "./files.ts";