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 pi from "./pi.ts"; import * as policy from "./policy.ts"; import * as schedules from "./schedules.ts"; import * as search from "./search.ts"; import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts"; import * as sources from "./sources.ts"; export * from "./search.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), list: ep({ method: "get", path: "/sites", scope: "read", summary: "站点列表(include=accounts 附带账号与整体账号状态)", query: sources.SiteListQuery, response: cursorPage(sources.SiteListItem), }), remove: del("/sites/{id}", "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)"), healthCheck: ep({ method: "post", path: "/sites/{id}/health-check", scope: "admin", summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列", params: IdParam, response: sources.SiteHealthResult, }), refreshLogin: ep({ method: "post", path: "/sites/{id}/refresh-login", scope: "admin", summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据", params: IdParam, body: sources.SiteRefreshLoginInput, response: sources.SiteRefreshLoginAccepted, }), userData: ep({ method: "get", path: "/sites/{id}/user-data", scope: "read", summary: "站点账号用户数据按天历史", params: IdParam, query: sources.SiteUserDataQuery, response: z.object({ items: z.array(sources.SiteUserDataPoint) }), }), 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), }), credentials: ep({ method: "put", path: "/sites/{id}/credentials", scope: "admin", summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)", 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, ), remove: del("/site-accounts/{id}", "删除站点账号与其凭证"), }, 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: "订阅进度:季总集数账本、每集状态(已覆盖/在途/缺失/疑似缺集)、版本目标在途与延后", 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, ), // ── 规则(W9-D):规则组 = 收录过滤条件 + 偏好标签合一。静态路径先于 /rules/{id} 注册 ── rules: { keywords: ep({ method: "get", path: "/rules/keywords", scope: "read", summary: "关键字表:内置(对齐 MP,含判定依据与上游正则)+ 自定义条件", response: policy.RuleKeywordList, }), parse: ep({ method: "post", path: "/rules/parse", scope: "read", summary: "规则串 → 结构化层(`>` 分层,层内 ! & | 与括号);错误逐条给位置与相近名", body: policy.RuleParseRequest, response: policy.RuleParseResult, }), serialize: ep({ method: "post", path: "/rules/serialize", scope: "read", summary: "结构化层 → 规范化规则串", body: policy.RuleSerializeRequest, response: policy.RuleSerializeResult, }), defaults: ep({ method: "get", path: "/rules/defaults", scope: "read", summary: "三套默认规则组链:订阅 / 洗版 / 搜索", response: policy.RuleDefaults, }), setDefaults: ep({ method: "put", path: "/rules/defaults", scope: "admin", summary: "设置默认规则组链(按数组顺序串联,pri 取最后一个带层的组)", body: policy.RuleDefaults, response: policy.RuleDefaults, }), ...crud( "/rules", "规则组", { row: policy.RuleGroup, create: policy.RuleGroupCreate, update: policy.RuleGroupUpdate, filters: { kind: policy.RuleGroupKind.optional(), source: policy.RuleGroupSource.optional(), enabled: z.enum(["true", "false"]).optional(), includeDeleted: z.enum(["true", "false"]).optional(), q: z.string().max(80).optional(), }, }, R, ), /** 删除 = 标记 deleted_at + 写抑制记录 */ remove: del("/rules/{id}", "删除规则组(留抑制记录;被引用时 409)"), revisions: ep({ method: "get", path: "/rules/{id}/revisions", scope: "read", summary: "规则组修订历史(新→旧;取自审计事件)", params: IdParam, response: policy.RuleGroupRevisionList, }), preview: ep({ method: "post", path: "/rules/{id}/preview", scope: "read", summary: "试算:对最近 N 条已识别资源(或指定资源/标题)跑本组,给 pri、命中层与拒绝原因", params: IdParam, body: policy.RulePreviewRequest, response: policy.RulePreviewResult, }), }, ruleAtoms: { ...crud( "/rules/atoms", "自定义条件", { row: policy.RuleAtom, create: policy.RuleAtomCreate, update: policy.RuleAtomUpdate }, R, ), remove: del("/rules/atoms/{id}", "删除自定义条件(被规则组引用时 409)"), }, /** /policy-revisions 的新路径(旧路径保留为别名,见 docs/runbook/rules.md) */ rulePolicies: { list: ep({ method: "get", path: "/rules/policies", scope: "read", summary: "收录策略版本列表", query: listQuery({ policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }), response: cursorPage(policy.PolicyRevision), }), get: ep({ method: "get", path: "/rules/policies/{id}", scope: "read", summary: "收录策略版本", params: IdParam, response: policy.PolicyRevision, }), create: ep({ method: "post", path: "/rules/policies", scope: "admin", summary: "新建策略草案", body: policy.PolicyRevisionCreate, response: policy.PolicyRevision, }), update: ep({ method: "patch", path: "/rules/policies/{id}", scope: "admin", summary: "修改策略草案", params: IdParam, body: policy.PolicyRevisionUpdate, response: policy.PolicyRevision, }), preview: ep({ method: "post", path: "/rules/policies/{id}/preview", scope: "read", summary: "预览策略草案", params: IdParam, response: policy.PolicyRevision, }), publish: ep({ method: "post", path: "/rules/policies/{id}/publish", scope: "admin", summary: "发布策略版本", params: IdParam, body: z.object({ expectedRevision: z.number().int().min(1) }), response: policy.PolicyRevision, }), rollback: ep({ method: "post", path: "/rules/policies/{id}/rollback", scope: "admin", summary: "回滚到该策略版本", params: IdParam, response: policy.PolicyRevision, }), }, /** /preference-suppressions 的新路径(旧路径保留为别名) */ ruleSuppressions: crud( "/rules/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, }), // W9-H 文件管理 v2 后端缺口 entityFromCandidate: ep({ method: "post", path: "/files/entity-from-candidate", scope: "organize.execute", summary: "按 TMDB 候选就地建本地作品 + 外部映射(accepted/manual)+ 季集单元;幂等", body: files.FilesEntityFromCandidateRequest, response: files.FilesEntityFromCandidateResult, }), scrapeBatch: ep({ method: "post", path: "/files/scrape-batch", scope: "organize.execute", summary: "批量刷新元数据:按作品去重入队 refreshMetadata(锁定字段不变)", body: files.FilesScrapeBatchRequest, response: files.FilesScrapeBatchResult, }), tree: ep({ method: "get", path: "/files/tree", scope: "organize.read", summary: "目录摘要:上一级、子目录(是否有下级)、视频/已登记计数(树与上一级用)", query: files.FilesTreeQuery, response: files.FilesTreeResult, }), recordSources: ep({ method: "get", path: "/files/record-sources", scope: "organize.read", summary: "整理记录的源路径换算成登记目录 FileRef(重新整理用)", query: files.FilesRecordSourcesQuery, response: files.FilesRecordSourcesResult, }), suggestName: ep({ method: "post", path: "/files/suggest-name", scope: "organize.read", summary: "按命名模板生成推荐名(命名引擎未就绪时 501)", body: files.FilesSuggestNameRequest, response: files.FilesSuggestNameResult, }), }, // ── 能力 ── 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, }), }, // ── Pi 模型上游(W7-A):多上游共存 + 订阅登录;写操作与登录只接受控制台会话 ── pi: { providers: ep({ method: "get", path: "/pi/providers", scope: "admin", summary: "Pi provider 能力表(认证方式、订阅登录方式与条款提示、baseUrl 覆盖、env 字段、静态模型表)", query: pi.PiProvidersQuery, response: pi.PiProvidersResponse, }), list: ep({ method: "get", path: "/pi/upstreams", scope: "admin", summary: "Agent 上游列表(不含密钥)", response: pi.PiUpstreamList, }), get: ep({ method: "get", path: "/pi/upstreams/{id}", scope: "admin", summary: "Agent 上游详情", params: IdParam, response: pi.PiUpstream, }), create: ep({ method: "post", path: "/pi/upstreams", scope: "admin", auth: "session", summary: "新建 Agent 上游(apiKey 只写,存 secret 表)", body: pi.PiUpstreamCreate, response: pi.PiUpstream, }), update: ep({ method: "patch", path: "/pi/upstreams/{id}", scope: "admin", auth: "session", summary: "修改 Agent 上游(provider 不可改;makeDefault 设默认上游)", params: IdParam, body: pi.PiUpstreamUpdate, response: pi.PiUpstream, }), remove: ep({ method: "delete", path: "/pi/upstreams/{id}", scope: "admin", auth: "session", summary: "删除 Agent 上游(连同凭据;是默认上游时清空默认)", params: IdParam, response: OkResult, }), test: ep({ method: "post", path: "/pi/upstreams/{id}/test", scope: "admin", auth: "session", summary: "测试上游:默认只解析凭据与校验模型(不产生费用);mode=completion 发一次最小补全", params: IdParam, body: pi.PiUpstreamTestRequest, response: pi.PiUpstreamTestResult, }), models: ep({ method: "get", path: "/pi/upstreams/{id}/models", scope: "admin", summary: "上游可用模型(静态目录;Radius 先动态刷新)", params: IdParam, response: pi.PiUpstreamModels, }), oauthStart: ep({ method: "post", path: "/pi/upstreams/{id}/oauth/start", scope: "admin", auth: "session", summary: "开始订阅登录:返回授权链接(browser)或验证码(device)", params: IdParam, body: pi.PiOAuthStartRequest, response: pi.PiOAuthStartResult, }), oauthComplete: ep({ method: "post", path: "/pi/upstreams/{id}/oauth/complete", scope: "admin", auth: "session", summary: "完成订阅登录:browser 粘回地址栏 URL;device 服务端轮询(本次最多等 waitMs,未完成返回 pending)", params: IdParam, body: pi.PiOAuthCompleteRequest, response: pi.PiOAuthCompleteResult, }), oauthLogout: ep({ method: "post", path: "/pi/upstreams/{id}/oauth/logout", scope: "admin", auth: "session", summary: "退出订阅登录:删除本地 token(不会撤销提供方侧授权)", params: IdParam, response: pi.PiUpstream, }), }, // ── 登录门禁 ── /** /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: "作品详情:实际路径、身份映射、元数据来源、各版本目标、候选拒绝理由、下载历史与校验结果", 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: "库存叠加(站点工作台):按作品返回已有版本 / 版本目标缺口 / 下载中", 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()) }), }), }, // ── 全局搜索(W9-G):顶栏搜索 / 命令面板 / 资源结果页 ── search: { all: ep({ method: "get", path: "/search", scope: "read", summary: "全局搜索聚合:本地作品 / TMDB 候选 / 跨站资源(15s 超时、并发合并、30s 缓存、库存叠加)/ 页面与操作;每组带 tookMs", query: search.SearchQuery, response: search.SearchResponse, }), releases: ep({ method: "get", path: "/search/releases", scope: "read", summary: "跨站资源结果页:站点/分辨率/来源/大小/免费/H&R 筛选、排序、游标;与 /search 共用缓存", query: search.ReleaseSearchQuery, response: search.ReleaseSearchPage, }), plan: ep({ method: "post", path: "/search/releases/{siteId}/{siteReleaseId}/plan", scope: "organize.execute", summary: "从搜索结果直接下载:定位资源后走 POST /acquisition-plans 同一流程", params: search.SearchReleaseParams, body: search.SearchReleasePlanRequest, response: execution.ManualAcquisitionResult, }), }, // ── 定时任务看板(W7-C):统一注册表 + Agent 巡检;全部只接受控制台会话 ── schedules: { list: ep({ method: "get", path: "/schedules", scope: "admin", auth: "session", summary: "定时任务看板:全部登记的周期任务与 Agent 巡检、统计、调度器是否在线", response: schedules.ScheduleBoard, }), create: ep({ method: "post", path: "/schedules", scope: "admin", auth: "session", summary: "新建自定义 Agent 巡检(目标模板 + 能力白名单 + 预算 + 间隔)", body: schedules.AgentCheckCreate, response: schedules.ScheduledJob, }), update: ep({ method: "patch", path: "/schedules/{id}", scope: "admin", auth: "session", summary: "修改开关 / 间隔或 cron / 参数;reset 恢复默认开关与调度", params: schedules.ScheduleIdParam, body: schedules.ScheduleUpdate, response: schedules.ScheduledJob, }), remove: ep({ method: "delete", path: "/schedules/{id}", scope: "admin", auth: "session", summary: "删除自定义 Agent 巡检(内置任务不可删)", params: schedules.ScheduleIdParam, response: OkResult, }), runNow: ep({ method: "post", path: "/schedules/{id}/run-now", scope: "admin", auth: "session", summary: "立即执行一次(不改下次运行时间;已在排队或运行中则合并)", params: schedules.ScheduleIdParam, response: schedules.ScheduleRunNowResult, }), skip: ep({ method: "post", path: "/schedules/{id}/skip", scope: "admin", auth: "session", summary: "跳过下一次运行(下次运行时间顺延一个周期)", params: schedules.ScheduleIdParam, response: schedules.ScheduledJob, }), runs: ep({ method: "get", path: "/schedules/{id}/runs", scope: "admin", auth: "session", summary: "最近运行历史(新→旧)", params: schedules.ScheduleIdParam, query: schedules.ScheduleRunsQuery, response: z.object({ items: z.array(schedules.ScheduledJobRun) }), }), }, } 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"; export * from "./schedules.ts";