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]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:37 +08:00
1 parent 7ba712ea55
commit cc8b3a7df4
42 files changed
+7026 -25

No files matched your search

+284
View File
@@ -0,0 +1,284 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 人工/AI 域:Agent 运行、证据/计划产物、待处理事项、审批、持续授权(REQ §10、§11.3、§11.4)
export const AgentTrigger = z.enum(["user", "event", "schedule", "subagent"]);
export type AgentTrigger = z.infer<typeof AgentTrigger>;
export const AgentRunStatus = z.enum([
"queued",
"running",
"waiting",
"succeeded",
"failed",
"cancelled",
"budget_exceeded",
]);
export type AgentRunStatus = z.infer<typeof AgentRunStatus>;
/** 单次运行预算(调用次数/token/时长/费用,REQ §10.5) */
export const AgentBudget = z.object({
maxToolCalls: z.number().int().positive().optional(),
maxTokens: z.number().int().positive().optional(),
maxDurationSec: z.number().int().positive().optional(),
maxCostUsd: z.number().nonnegative().optional(),
});
export type AgentBudget = z.infer<typeof AgentBudget>;
export const AgentUsage = z.object({
inputTokens: NonNegInt.optional(),
outputTokens: NonNegInt.optional(),
cacheReadTokens: NonNegInt.optional(),
cacheWriteTokens: NonNegInt.optional(),
costUsd: z.number().nonnegative().optional(),
durationMs: NonNegInt.optional(),
});
export type AgentUsage = z.infer<typeof AgentUsage>;
export const AgentRun = EntityBase.extend({
taskId: Id.nullable(),
parentRunId: Id.nullable(),
trigger: AgentTrigger,
goal: z.string().min(1),
status: AgentRunStatus,
provider: z.string().nullable(),
model: z.string().nullable(),
thinkingLevel: z.string().nullable(),
/** 授权范围(能力白名单、对象范围);子代理继承父任务上限 */
scope: JsonObject,
budget: AgentBudget,
usage: AgentUsage,
evidenceVersion: z.string().nullable(),
/** Pi 会话持久化状态;不对普通列表返回 */
sessionState: JsonObject.nullable(),
summary: z.string().nullable(),
toolCallCount: NonNegInt,
lastEventSeq: NonNegInt,
error: JsonObject.nullable(),
cancelRequestedAt: Timestamp.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
createdBy: z.string().nullable(),
});
export type AgentRun = z.infer<typeof AgentRun>;
export const AgentRunCreate = AgentRun.pick({
taskId: true,
parentRunId: true,
trigger: true,
goal: true,
scope: true,
budget: true,
}).partial({ taskId: true, parentRunId: true, trigger: true, scope: true, budget: true });
export type AgentRunCreate = z.infer<typeof AgentRunCreate>;
export const AgentRunUpdate = AgentRun.pick({
status: true,
provider: true,
model: true,
thinkingLevel: true,
usage: true,
evidenceVersion: true,
sessionState: true,
summary: true,
toolCallCount: true,
lastEventSeq: true,
error: true,
startedAt: true,
finishedAt: true,
}).partial();
export type AgentRunUpdate = z.infer<typeof AgentRunUpdate>;
export const ArtifactKind = z.enum(["evidence", "plan", "proposal", "report", "snapshot", "log"]);
export type ArtifactKind = z.infer<typeof ArtifactKind>;
/** 证据/计划/提案产物:提案不直接晋升为事实 */
export const Artifact = EntityBase.extend({
kind: ArtifactKind,
agentRunId: Id.nullable(),
taskId: Id.nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
title: z.string().min(1),
mimeType: z.string(),
content: JsonObject,
contentHash: z.string().min(1),
sizeBytes: NonNegInt.nullable(),
expiresAt: Timestamp.nullable(),
});
export type Artifact = z.infer<typeof Artifact>;
/** contentHash / sizeBytes 由服务端计算 */
export const ArtifactCreate = Artifact.omit({ ...SERVER_MANAGED, contentHash: true, sizeBytes: true }).partial({
agentRunId: true,
taskId: true,
subjectType: true,
subjectId: true,
mimeType: true,
expiresAt: true,
});
export type ArtifactCreate = z.infer<typeof ArtifactCreate>;
/** 产物内容不可变;只能调整过期时间 */
export const ArtifactUpdate = z.object({ expiresAt: Timestamp.nullable() });
export type ArtifactUpdate = z.infer<typeof ArtifactUpdate>;
export const DecisionKind = z.enum([
"identity_ambiguous",
"download_stalled",
"download_failed",
"path_conflict",
"space_insufficient",
"approval_required",
"metadata_conflict",
"source_unreachable",
"other",
]);
export type DecisionKind = z.infer<typeof DecisionKind>;
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
export type DecisionStatus = z.infer<typeof DecisionStatus>;
export const Severity = z.enum(["info", "warning", "critical"]);
export type Severity = z.infer<typeof Severity>;
export const DecisionOption = z.object({
key: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
/** 选择后执行的能力与参数(仍经授权校验) */
capabilityId: z.string().optional(),
input: JsonObject.optional(),
});
export type DecisionOption = z.infer<typeof DecisionOption>;
/** 待处理中心条目;同一 dedupeKey 同时只有一个未关闭条目 */
export const Decision = EntityBase.extend({
kind: DecisionKind,
status: DecisionStatus,
severity: Severity,
title: z.string().min(1),
summary: z.string().nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
dedupeKey: z.string().min(1),
evidence: JsonObject,
options: z.array(DecisionOption),
resolution: JsonObject.nullable(),
taskId: Id.nullable(),
agentRunId: Id.nullable(),
attempts: NonNegInt,
nextReviewAt: Timestamp.nullable(),
resolvedBy: z.string().nullable(),
resolvedAt: Timestamp.nullable(),
});
export type Decision = z.infer<typeof Decision>;
export const DecisionCreate = Decision.pick({
kind: true,
severity: true,
title: true,
summary: true,
subjectType: true,
subjectId: true,
dedupeKey: true,
evidence: true,
options: true,
taskId: true,
agentRunId: true,
nextReviewAt: true,
}).partial({
severity: true,
summary: true,
subjectType: true,
subjectId: true,
evidence: true,
options: true,
taskId: true,
agentRunId: true,
nextReviewAt: true,
});
export type DecisionCreate = z.infer<typeof DecisionCreate>;
export const DecisionUpdate = Decision.pick({
status: true,
severity: true,
summary: true,
evidence: true,
options: true,
agentRunId: true,
nextReviewAt: true,
}).partial();
export type DecisionUpdate = z.infer<typeof DecisionUpdate>;
/** 用户处理待处理事项:选一个选项或关闭 */
export const DecisionResolveInput = z.object({
optionKey: z.string().min(1).optional(),
dismiss: z.boolean().optional(),
note: z.string().max(2000).optional(),
});
export type DecisionResolveInput = z.infer<typeof DecisionResolveInput>;
export const ApprovalStatus = z.enum(["pending", "approved", "rejected", "revoked", "expired", "consumed"]);
export type ApprovalStatus = z.infer<typeof ApprovalStatus>;
/** 单次审批:绑定 planType+planId + payloadHash + expectedRevision;换候选/路径后旧审批失效 */
export const Approval = EntityBase.extend({
planType: z.string().min(1),
planId: Id,
payloadHash: z.string().min(1),
expectedRevision: z.number().int().min(0),
scope: JsonObject,
status: ApprovalStatus,
decisionId: Id.nullable(),
requestedBy: z.string().min(1),
decidedBy: z.string().nullable(),
decidedAt: Timestamp.nullable(),
expiresAt: Timestamp.nullable(),
reason: z.string().nullable(),
});
export type Approval = z.infer<typeof Approval>;
export const ApprovalCreate = Approval.pick({
planType: true,
planId: true,
payloadHash: true,
expectedRevision: true,
scope: true,
decisionId: true,
requestedBy: true,
expiresAt: true,
reason: true,
}).partial({ scope: true, decisionId: true, expiresAt: true, reason: true });
export type ApprovalCreate = z.infer<typeof ApprovalCreate>;
/** 批准/拒绝时回传 payloadHash,服务端比对防止审批被挪用到变更后的计划 */
export const ApprovalUpdate = z.object({
status: z.enum(["approved", "rejected", "revoked"]),
payloadHash: z.string().min(1),
reason: z.string().max(2000).optional(),
});
export type ApprovalUpdate = z.infer<typeof ApprovalUpdate>;
export const GrantStatus = z.enum(["active", "revoked", "expired"]);
export type GrantStatus = z.infer<typeof GrantStatus>;
/** 持续授权:绑定策略版本、动作、对象范围与预算 */
export const AutomationGrant = EntityBase.extend({
action: z.string().min(1),
policyRevisionId: Id.nullable(),
scope: JsonObject,
budget: JsonObject,
status: GrantStatus,
grantedBy: z.string().min(1),
reason: z.string().nullable(),
expiresAt: Timestamp.nullable(),
revokedAt: Timestamp.nullable(),
revokedBy: z.string().nullable(),
});
export type AutomationGrant = z.infer<typeof AutomationGrant>;
export const AutomationGrantCreate = AutomationGrant.pick({
action: true,
policyRevisionId: true,
scope: true,
budget: true,
reason: true,
expiresAt: true,
}).partial({ policyRevisionId: true, scope: true, budget: true, reason: true, expiresAt: true });
export type AutomationGrantCreate = z.infer<typeof AutomationGrantCreate>;
/** 只能撤销或缩短有效期,不能就地扩大范围(扩大 = 新授权) */
export const AutomationGrantUpdate = z.object({
status: z.literal("revoked").optional(),
expiresAt: Timestamp.nullable().optional(),
reason: z.string().max(2000).optional(),
});
export type AutomationGrantUpdate = z.infer<typeof AutomationGrantUpdate>;
+783
View File
@@ -0,0 +1,783 @@
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"]>;
+188
View File
@@ -0,0 +1,188 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
import { Segment } from "./content";
// 文件域:资产、资产-内容单元覆盖、资产位置、路径绑定(REQ §7.1、§8.2、§12.2)
// 资产与位置分离;path_binding 一旦建立不因元数据刷新改变,只有显式整理可替换(旧绑定标 superseded)。
export const AssetKind = z.enum(["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"]);
export type AssetKind = z.infer<typeof AssetKind>;
export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]);
export type AssetStatus = z.infer<typeof AssetStatus>;
/** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 contentEdition 分开(REQ §6.1) */
export const DeliveryTraits = z.object({
resolution: z.string().optional(),
videoCodec: z.string().optional(),
audioCodecs: z.array(z.string()).optional(),
hdr: z.array(z.string()).optional(),
sourceType: z.string().optional(),
releaseGroup: z.string().optional(),
subtitles: z.array(z.string()).optional(),
});
export type DeliveryTraits = z.infer<typeof DeliveryTraits>;
export const MediaAsset = EntityBase.extend({
mediaEntityId: Id.nullable(),
kind: AssetKind,
/** 附属文件(字幕/音轨)指向主视频资产 */
parentAssetId: Id.nullable(),
sizeBytes: NonNegInt.nullable(),
contentHash: z.string().nullable(),
hashAlgorithm: z.string().nullable(),
sampleDigest: z.string().nullable(),
/** ffprobe/MediaInfo 结果 */
mediaInfo: JsonObject,
delivery: DeliveryTraits,
/** 内容版本:院线版/导演剪辑版等 */
contentEdition: z.string().nullable(),
sourceReleaseId: Id.nullable(),
downloadTaskId: Id.nullable(),
status: AssetStatus,
verifiedAt: Timestamp.nullable(),
});
export type MediaAsset = z.infer<typeof MediaAsset>;
export const MediaAssetCreate = MediaAsset.omit(SERVER_MANAGED).partial({
mediaEntityId: true,
parentAssetId: true,
sizeBytes: true,
contentHash: true,
hashAlgorithm: true,
sampleDigest: true,
mediaInfo: true,
delivery: true,
contentEdition: true,
sourceReleaseId: true,
downloadTaskId: true,
status: true,
verifiedAt: true,
});
export type MediaAssetCreate = z.infer<typeof MediaAssetCreate>;
export const MediaAssetUpdate = MediaAssetCreate.partial();
export type MediaAssetUpdate = z.infer<typeof MediaAssetUpdate>;
export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]);
export type CoverageKind = z.infer<typeof CoverageKind>;
/** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */
export const AssetUnitCoverage = EntityBase.extend({
mediaAssetId: Id,
contentUnitId: Id,
coverage: CoverageKind,
segment: Segment.nullable(),
evidence: JsonObject,
});
export type AssetUnitCoverage = z.infer<typeof AssetUnitCoverage>;
export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({
coverage: true,
segment: true,
evidence: true,
});
export type AssetUnitCoverageCreate = z.infer<typeof AssetUnitCoverageCreate>;
export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({
mediaAssetId: true,
contentUnitId: true,
}).partial();
export type AssetUnitCoverageUpdate = z.infer<typeof AssetUnitCoverageUpdate>;
export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]);
export type LocationKind = z.infer<typeof LocationKind>;
/** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */
export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]);
export type LocationStatus = z.infer<typeof LocationStatus>;
export const AssetLocation = EntityBase.extend({
mediaAssetId: Id,
locationKind: LocationKind,
libraryRootId: Id.nullable(),
downloaderInstanceId: Id.nullable(),
/** 相对库根(或下载器保存根)的路径 */
relativePath: z.string().min(1),
status: LocationStatus,
isPrimary: z.boolean(),
sizeBytes: NonNegInt.nullable(),
inode: z.number().int().nullable(),
device: z.number().int().nullable(),
mtime: Timestamp.nullable(),
sidecarOfLocationId: Id.nullable(),
lastSeenAt: Timestamp.nullable(),
lastCheckedAt: Timestamp.nullable(),
checkDetail: JsonObject,
});
export type AssetLocation = z.infer<typeof AssetLocation>;
export const AssetLocationCreate = AssetLocation.omit(SERVER_MANAGED)
.partial({
locationKind: true,
libraryRootId: true,
downloaderInstanceId: true,
status: true,
isPrimary: true,
sizeBytes: true,
inode: true,
device: true,
mtime: true,
sidecarOfLocationId: true,
lastSeenAt: true,
lastCheckedAt: true,
checkDetail: true,
})
.refine((v) => (v.locationKind ?? "library") !== "library" || !!v.libraryRootId, {
message: "library 位置必须指定 libraryRootId",
});
export type AssetLocationCreate = z.infer<typeof AssetLocationCreate>;
export const AssetLocationUpdate = AssetLocation.pick({
status: true,
isPrimary: true,
sizeBytes: true,
inode: true,
device: true,
mtime: true,
lastSeenAt: true,
lastCheckedAt: true,
checkDetail: true,
}).partial();
export type AssetLocationUpdate = z.infer<typeof AssetLocationUpdate>;
export const BindingKind = z.enum(["entity", "season"]);
export type BindingKind = z.infer<typeof BindingKind>;
export const BindingStatus = z.enum(["active", "conflict", "superseded"]);
export type BindingStatus = z.infer<typeof BindingStatus>;
export const PathBinding = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nonnegative().nullable(),
bindingKind: BindingKind,
libraryRootId: Id,
relativePath: z.string().min(1),
templateVersion: z.string().nullable(),
status: BindingStatus,
boundAt: Timestamp,
boundBy: z.string().nullable(),
supersededById: Id.nullable(),
note: z.string().nullable(),
});
export type PathBinding = z.infer<typeof PathBinding>;
export const PathBindingCreate = PathBinding.pick({
mediaEntityId: true,
contentUnitId: true,
seasonNumber: true,
bindingKind: true,
libraryRootId: true,
relativePath: true,
templateVersion: true,
status: true,
note: true,
})
.partial({ contentUnitId: true, seasonNumber: true, templateVersion: true, status: true, note: true })
.refine((v) => (v.bindingKind === "season") === (v.seasonNumber !== undefined && v.seasonNumber !== null), {
message: "season 绑定必须给 seasonNumber,entity 绑定不能给",
});
export type PathBindingCreate = z.infer<typeof PathBindingCreate>;
/** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */
export const PathBindingUpdate = z.object({
status: BindingStatus.optional(),
supersededById: Id.nullable().optional(),
note: z.string().nullable().optional(),
});
export type PathBindingUpdate = z.infer<typeof PathBindingUpdate>;
+218
View File
@@ -0,0 +1,218 @@
import { z } from "zod";
import { MediaAsset } from "./assets";
import type { KnownCapabilityId } from "./capability";
import { Id, JsonObject, NonNegInt } from "./common";
import { MediaEntity, MediaKind } from "./content";
import { AcquisitionPlan, DownloadTask, Task, TaskStatus } from "./execution";
import { ExternalEntityType, ExternalIdentity } from "./identity";
import { ServerMapping } from "./ops";
import { OrganizationAction, OrganizationRecord } from "./organization";
import { AcquisitionSlot, PolicyRevision } from "./policy";
import { SourceRelease } from "./sources";
// 首批能力的输入/输出 schema;名字与 CAPABILITY_CATALOG 中的 inputSchema/outputSchema 一致。
// 写能力的输入只接受计划/对象 ID,不接受模型自拟的幂等键或任意路径。
const Limit = z.number().int().min(1).max(100).default(20);
/** 写能力的通用受理结果:返回 task/plan ID,由任务页追踪(REQ §12.3) */
export const Accepted = z.object({ taskId: Id, planId: Id.nullable() });
export type Accepted = z.infer<typeof Accepted>;
export const CatalogLookupInput = z
.object({
query: z.string().min(1).max(200).optional(),
externalId: z.object({ provider: z.string(), entityType: ExternalEntityType, externalId: z.string() }).optional(),
kind: MediaKind.optional(),
year: z.number().int().optional(),
includeRemote: z.boolean().default(false),
limit: Limit,
})
.refine((v) => !!v.query || !!v.externalId, { message: "query 与 externalId 至少给一个" });
export const CatalogLookupOutput = z.object({
local: z.array(z.object({ entity: MediaEntity, score: z.number(), matchedAlias: z.string().nullable() })),
remote: z.array(
z.object({
provider: z.string(),
entityType: ExternalEntityType,
externalId: z.string(),
title: z.string(),
year: z.number().int().nullable(),
}),
),
});
export const ReleaseSearchInput = z.object({
query: z.string().min(1).max(200).optional(),
mediaEntityId: Id.optional(),
siteIds: z.array(Id).optional(),
/** 本地快照不足时是否实时补查 */
allowLiveSearch: z.boolean().default(true),
limit: Limit,
});
export const ReleaseSearchOutput = z.object({
items: z.array(SourceRelease),
/** 各站点快照新鲜度与是否实时补查 */
coverage: z.array(
z.object({ siteId: Id, fromSnapshot: z.boolean(), live: z.boolean(), error: z.string().nullable() }),
),
});
export const ReleaseInspectInput = z.object({ sourceReleaseId: Id, refreshDetail: z.boolean().default(false) });
export const ReleaseInspectOutput = z.object({ release: SourceRelease, inventory: z.array(MediaAsset) });
export const IdentityResolveInput = z
.object({
observationId: Id.optional(),
sourceReleaseId: Id.optional(),
candidate: z
.object({
mediaEntityId: Id.optional(),
provider: z.string().optional(),
entityType: ExternalEntityType.optional(),
externalId: z.string().optional(),
})
.optional(),
evidenceIds: z.array(Id).default([]),
})
.refine((v) => !!v.observationId || !!v.sourceReleaseId, { message: "需要 observationId 或 sourceReleaseId" });
export const IdentityResolveOutput = z.object({
status: z.enum(["resolved", "proposed", "ambiguous", "unidentifiable"]),
mediaEntityId: Id.nullable(),
identities: z.array(ExternalIdentity),
decisionId: Id.nullable(),
});
export const DownloadPlanInput = z.object({ slotId: Id, sourceReleaseId: Id });
export const DownloadPlanOutput = z.object({
plan: AcquisitionPlan.nullable(),
slot: AcquisitionSlot,
/** 硬条件不满足或预算/阈值阻止时的原因 */
rejected: z.array(z.object({ code: z.string(), message: z.string() })),
requiresApproval: z.boolean(),
});
export const DownloadSubmitInput = z.object({ planId: Id, approvalId: Id.optional() });
export const DownloadSubmitOutput = Accepted.extend({ downloadTask: DownloadTask.nullable() });
export const OrganizePlanInput = z
.object({
downloadTaskId: Id.optional(),
mediaAssetIds: z.array(Id).optional(),
action: OrganizationAction.default("copy"),
targetLibraryRootId: Id.optional(),
})
.refine((v) => !!v.downloadTaskId || !!v.mediaAssetIds?.length, { message: "需要 downloadTaskId 或 mediaAssetIds" });
export const OrganizePlanOutput = z.object({
planId: Id,
payloadHash: z.string(),
files: z.array(
z.object({
sourcePath: z.string(),
targetPath: z.string().nullable(),
action: OrganizationAction,
skipReason: z.string().nullable(),
}),
),
conflicts: z.array(z.object({ code: z.string(), message: z.string(), path: z.string().nullable() })),
requiresApproval: z.boolean(),
});
export const OrganizeExecuteInput = z.object({ planId: Id, payloadHash: z.string().min(1), approvalId: Id.optional() });
export const OrganizeExecuteOutput = Accepted.extend({ record: OrganizationRecord.nullable() });
export const TaskInspectInput = z.object({ taskId: Id });
export const TaskInspectOutput = z.object({
task: Task,
children: z.array(Task),
timeline: z.array(z.object({ at: z.string(), type: z.string(), summary: z.string(), data: JsonObject.optional() })),
});
export const TaskRetryInput = z.object({ taskId: Id, reason: z.string().max(500).optional() });
export const TaskRetryOutput = z.object({ taskId: Id, status: TaskStatus });
export const TaskCancelInput = z.object({ taskId: Id, reason: z.string().min(1).max(500) });
export const TaskCancelOutput = z.object({ taskId: Id, status: TaskStatus, cancelRequested: z.boolean() });
export const PolicyPreviewInput = z.object({
policyRevisionId: Id,
sampleLimit: z.number().int().min(1).max(1000).default(200),
});
export const PolicyPreviewOutput = z.object({
newlyMatched: NonNegInt,
newlyExcluded: NonNegInt,
samples: z.array(z.object({ sourceReleaseId: Id, before: z.string(), after: z.string(), reason: z.string() })),
});
export const PolicyApplyInput = z.object({
policyRevisionId: Id,
expectedRevision: z.number().int().min(1),
approvalId: Id.optional(),
});
export const PolicyApplyOutput = z.object({ policy: PolicyRevision });
export const InventoryQueryInput = z.object({
mediaEntityId: Id.optional(),
query: z.string().max(200).optional(),
includeServerInventory: z.boolean().default(false),
limit: Limit,
});
export const InventoryQueryOutput = z.object({
items: z.array(
z.object({
entity: MediaEntity,
assets: z.array(MediaAsset),
slots: z.array(AcquisitionSlot),
identities: z.array(ExternalIdentity.pick({ provider: true, entityType: true, externalId: true, status: true })),
/** 外部库存证据;"未知 ≠ 缺失" */
server: z.array(ServerMapping),
}),
),
});
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
export const CAPABILITY_IO = {
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
"release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput },
"release.inspect": { input: ReleaseInspectInput, output: ReleaseInspectOutput },
"identity.resolve": { input: IdentityResolveInput, output: IdentityResolveOutput },
"download.plan": { input: DownloadPlanInput, output: DownloadPlanOutput },
"download.submit": { input: DownloadSubmitInput, output: DownloadSubmitOutput },
"organize.plan": { input: OrganizePlanInput, output: OrganizePlanOutput },
"organize.execute": { input: OrganizeExecuteInput, output: OrganizeExecuteOutput },
"task.inspect": { input: TaskInspectInput, output: TaskInspectOutput },
"task.retry": { input: TaskRetryInput, output: TaskRetryOutput },
"task.cancel": { input: TaskCancelInput, output: TaskCancelOutput },
"policy.preview": { input: PolicyPreviewInput, output: PolicyPreviewOutput },
"policy.apply": { input: PolicyApplyInput, output: PolicyApplyOutput },
"inventory.query": { input: InventoryQueryInput, output: InventoryQueryOutput },
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
export type CatalogLookupInput = z.infer<typeof CatalogLookupInput>;
export type CatalogLookupOutput = z.infer<typeof CatalogLookupOutput>;
export type ReleaseSearchInput = z.infer<typeof ReleaseSearchInput>;
export type ReleaseSearchOutput = z.infer<typeof ReleaseSearchOutput>;
export type ReleaseInspectInput = z.infer<typeof ReleaseInspectInput>;
export type ReleaseInspectOutput = z.infer<typeof ReleaseInspectOutput>;
export type IdentityResolveInput = z.infer<typeof IdentityResolveInput>;
export type IdentityResolveOutput = z.infer<typeof IdentityResolveOutput>;
export type DownloadPlanInput = z.infer<typeof DownloadPlanInput>;
export type DownloadPlanOutput = z.infer<typeof DownloadPlanOutput>;
export type DownloadSubmitInput = z.infer<typeof DownloadSubmitInput>;
export type DownloadSubmitOutput = z.infer<typeof DownloadSubmitOutput>;
export type OrganizePlanInput = z.infer<typeof OrganizePlanInput>;
export type OrganizePlanOutput = z.infer<typeof OrganizePlanOutput>;
export type OrganizeExecuteInput = z.infer<typeof OrganizeExecuteInput>;
export type OrganizeExecuteOutput = z.infer<typeof OrganizeExecuteOutput>;
export type TaskInspectInput = z.infer<typeof TaskInspectInput>;
export type TaskInspectOutput = z.infer<typeof TaskInspectOutput>;
export type TaskRetryInput = z.infer<typeof TaskRetryInput>;
export type TaskRetryOutput = z.infer<typeof TaskRetryOutput>;
export type TaskCancelInput = z.infer<typeof TaskCancelInput>;
export type TaskCancelOutput = z.infer<typeof TaskCancelOutput>;
export type PolicyPreviewInput = z.infer<typeof PolicyPreviewInput>;
export type PolicyPreviewOutput = z.infer<typeof PolicyPreviewOutput>;
export type PolicyApplyInput = z.infer<typeof PolicyApplyInput>;
export type PolicyApplyOutput = z.infer<typeof PolicyApplyOutput>;
export type InventoryQueryInput = z.infer<typeof InventoryQueryInput>;
export type InventoryQueryOutput = z.infer<typeof InventoryQueryOutput>;
+235
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { ApiScope } from "./common";
/**
* 能力调用上下文(REQ §10.3)。产品自有接口,不是 Pi SDK 原生 API。
@@ -43,3 +44,237 @@ export interface CapabilityDefinition<I extends z.ZodType = z.ZodType, O extends
idempotent: boolean;
supportsPreview: boolean;
}
// ── 能力描述符与首批能力(任务书 §3.1-9;REQ §10.3) ──
/** 风险级别:low 只读;medium 预览/可撤回写入;high 触发下载、文件变更或策略发布 */
export const RiskLevel = z.enum(["low", "medium", "high"]);
export type RiskLevel = z.infer<typeof RiskLevel>;
/** 接口类别(REQ §12.3):read / preview / propose / approve / execute */
export const CapabilityKind = z.enum(["read", "preview", "propose", "approve", "execute"]);
export type CapabilityKind = z.infer<typeof CapabilityKind>;
/** 首批能力 ID */
export const CAPABILITY_IDS = [
"catalog.lookup",
"release.search",
"release.inspect",
"identity.resolve",
"download.plan",
"download.submit",
"organize.plan",
"organize.execute",
"task.inspect",
"task.retry",
"task.cancel",
"policy.preview",
"policy.apply",
"inventory.query",
] as const;
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
/** schema 引用:指向 contracts 中导出的 schema 名(如 "ReleaseSearchInput"),便于序列化给 UI/MCP 列表 */
export const SchemaRef = z.string().regex(/^[A-Z][A-Za-z0-9]*$/);
/** 能力注册中心的可序列化描述(处理器在 server 侧挂载;模型只能看到白名单内的描述) */
export const CapabilityDescriptor = z.object({
id: CapabilityId,
version: z.number().int().min(1),
title: z.string().min(1),
description: z.string().min(1),
kind: CapabilityKind,
inputSchema: SchemaRef,
outputSchema: SchemaRef,
/** 调用所需 API scope */
scope: ApiScope,
riskLevel: RiskLevel,
idempotent: z.boolean(),
supportsPreview: z.boolean(),
});
export type CapabilityDescriptor = z.infer<typeof CapabilityDescriptor>;
/** 首批能力的描述(inputSchema/outputSchema 名对应 capabilities-io.ts 中的导出) */
export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor> = {
"catalog.lookup": d(
"catalog.lookup",
"查作品",
"按标题/别名/外部 ID 查本地作品与元数据候选",
"read",
"CatalogLookup",
"read",
"low",
true,
false,
),
"release.search": d(
"release.search",
"搜资源",
"先查本地资源索引与站点快照,不足时对已开启站点实时补查",
"read",
"ReleaseSearch",
"read",
"low",
true,
false,
),
"release.inspect": d(
"release.inspect",
"看资源详情",
"读取站点资源详情、文件清单、促销与解析结果",
"read",
"ReleaseInspect",
"read",
"low",
true,
false,
),
"identity.resolve": d(
"identity.resolve",
"识别身份",
"为观察/资源提出身份映射;高证据自动接受,歧义进入待处理",
"propose",
"IdentityResolve",
"organize.execute",
"medium",
true,
true,
),
"download.plan": d(
"download.plan",
"生成下载计划",
"评估候选并生成带 Slot/预算预留的下载计划(不提交)",
"preview",
"DownloadPlan",
"organize.execute",
"medium",
true,
true,
),
"download.submit": d(
"download.submit",
"提交下载",
"按已保存的计划提交到下载器;校验授权与硬阈值",
"execute",
"DownloadSubmit",
"organize.execute",
"high",
true,
false,
),
"organize.plan": d(
"organize.plan",
"生成整理计划",
"冻结源/目标路径、操作类型与前置条件",
"preview",
"OrganizePlan",
"organize.read",
"low",
true,
true,
),
"organize.execute": d(
"organize.execute",
"执行整理",
"执行已保存的整理计划(Copy/Move),生成整理记录",
"execute",
"OrganizeExecute",
"organize.execute",
"high",
true,
false,
),
"task.inspect": d(
"task.inspect",
"查看任务",
"读取任务状态、业务时间线与错误",
"read",
"TaskInspect",
"read",
"low",
true,
false,
),
"task.retry": d(
"task.retry",
"重试任务",
"对失败/死信任务重新排队;副作用经操作账本去重",
"execute",
"TaskRetry",
"organize.execute",
"medium",
true,
false,
),
"task.cancel": d(
"task.cancel",
"取消任务",
"停止未来步骤,不撤销已完成的外部动作",
"execute",
"TaskCancel",
"organize.execute",
"medium",
true,
false,
),
"policy.preview": d(
"policy.preview",
"预览策略",
"用历史数据回放策略草案,展示新增命中与误排除",
"preview",
"PolicyPreview",
"read",
"low",
true,
true,
),
"policy.apply": d(
"policy.apply",
"发布策略",
"发布策略版本(需授权);不直接执行 JavaScript",
"execute",
"PolicyApply",
"admin",
"high",
true,
true,
),
"inventory.query": d(
"inventory.query",
"查库存",
"查询本地资产、Slot 满足度与外部库存证据",
"read",
"InventoryQuery",
"read",
"low",
true,
false,
),
};
function d(
id: KnownCapabilityId,
title: string,
description: string,
kind: CapabilityKind,
schemaBase: string,
scope: ApiScope,
riskLevel: RiskLevel,
idempotent: boolean,
supportsPreview: boolean,
): CapabilityDescriptor {
return {
id,
version: 1,
title,
description,
kind,
inputSchema: `${schemaBase}Input`,
outputSchema: `${schemaBase}Output`,
scope,
riskLevel,
idempotent,
supportsPreview,
};
}
+29
View File
@@ -91,3 +91,32 @@ export const HealthStatus = z.object({
time: Timestamp,
});
export type HealthStatus = z.infer<typeof HealthStatus>;
/** 日期(无时区),如 air_date:YYYY-MM-DD */
export const DateOnly = z.iso.date();
export type DateOnly = z.infer<typeof DateOnly>;
/** JSONB 对象列(证据、规则参数、原文快照等)的 API 表示 */
export const JsonObject = z.record(z.string(), z.unknown());
export type JsonObject = z.infer<typeof JsonObject>;
/** 字节数、计数等非负整数(DB 为 bigint,客户端按 number 解析,上限 2^53) */
export const NonNegInt = z.number().int().nonnegative();
/** 操作者类型:与 DB 中 actor_type 列一致 */
export const ActorType = z.enum(["user", "agent", "system", "api_key"]);
export type ActorType = z.infer<typeof ActorType>;
/** 实体行的公共字段 */
export const EntityBase = z.object({
id: Id,
createdAt: Timestamp,
updatedAt: Timestamp,
});
/** 所有实体 Create 输入都要剔除的服务端字段 */
export const SERVER_MANAGED = { id: true, createdAt: true, updatedAt: true } as const;
/** 路径参数 `{id}` */
export const IdParam = z.object({ id: Id });
export type IdParam = z.infer<typeof IdParam>;
+129
View File
@@ -0,0 +1,129 @@
import { z } from "zod";
import { DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 内容域:本地作品、内容单元、别名(REQ §3.1、§5.1、§5.4、§7.5)
// 本地 UUID 是主身份;季/集编号、外部 ID 都可修订,不是唯一身份。
export const MediaKind = z.enum(["movie", "series", "variety", "collection", "other"]);
export type MediaKind = z.infer<typeof MediaKind>;
export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]);
export type MediaEntityStatus = z.infer<typeof MediaEntityStatus>;
export const MediaEntity = EntityBase.extend({
kind: MediaKind,
title: z.string().min(1),
originalTitle: z.string().nullable(),
year: z.number().int().min(1870).max(2200).nullable(),
status: MediaEntityStatus,
/** status=merged 时指向合并后的实体(旧 ID 跳转) */
mergedIntoId: Id.nullable(),
/** 最终展示字段(每个字段带来源/语言/时间) */
metadata: JsonObject,
/** 人工锁定的字段名;刷新不得覆盖 */
fieldLocks: z.array(z.string()),
lastMetadataRefreshAt: Timestamp.nullable(),
});
export type MediaEntity = z.infer<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({
kind: true,
title: true,
originalTitle: true,
year: true,
status: true,
metadata: true,
fieldLocks: true,
}).partial({ originalTitle: true, year: true, status: true, metadata: true, fieldLocks: true });
export type MediaEntityCreate = z.infer<typeof MediaEntityCreate>;
/** 合并通过专门动作完成,Update 不接受 status=merged */
export const MediaEntityUpdate = MediaEntityCreate.partial().extend({
status: MediaEntityStatus.exclude(["merged"]).optional(),
});
export type MediaEntityUpdate = z.infer<typeof MediaEntityUpdate>;
export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]);
export type ContentUnitKind = z.infer<typeof ContentUnitKind>;
/** 综艺内容类型(REQ §5.1) */
export const ContentType = z.enum([
"main",
"extra",
"behind_the_scenes",
"performance_only",
"preview",
"reaction",
"unknown",
]);
export type ContentType = z.infer<typeof ContentType>;
export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]);
export type Segment = z.infer<typeof Segment>;
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
export const ContentUnit = EntityBase.extend({
mediaEntityId: Id,
parentUnitId: Id.nullable(),
kind: ContentUnitKind,
contentType: ContentType,
segment: Segment,
seasonNumber: z.number().int().nonnegative().nullable(),
episodeNumber: z.number().int().nonnegative().nullable(),
/** 来源期号原文,如 "第十期"、"20260927" */
sourceIssue: z.string().nullable(),
airDate: DateOnly.nullable(),
displayOrder: z.number().nullable(),
title: z.string().nullable(),
status: ContentUnitStatus,
/** 人工确认的编号锁定;刷新不得改编号 */
numberingLocked: z.boolean(),
metadata: JsonObject,
fieldLocks: z.array(z.string()),
});
export type ContentUnit = z.infer<typeof ContentUnit>;
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
parentUnitId: true,
contentType: true,
segment: true,
seasonNumber: true,
episodeNumber: true,
sourceIssue: true,
airDate: true,
displayOrder: true,
title: true,
status: true,
numberingLocked: true,
metadata: true,
fieldLocks: true,
});
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
export type ContentUnitUpdate = z.infer<typeof ContentUnitUpdate>;
export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]);
export type AliasKind = z.infer<typeof AliasKind>;
export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]);
export type AliasSource = z.infer<typeof AliasSource>;
export const TitleAlias = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
alias: z.string().min(1),
/** 归一化后的检索键(小写、去标点、全半角统一),建 trgm 索引 */
normalized: z.string().min(1),
language: z.string().nullable(),
kind: AliasKind,
source: AliasSource,
locked: z.boolean(),
});
export type TitleAlias = z.infer<typeof TitleAlias>;
/** normalized 由服务端计算,不接受客户端传入 */
export const TitleAliasCreate = TitleAlias.pick({
mediaEntityId: true,
contentUnitId: true,
alias: true,
language: true,
kind: true,
source: true,
locked: true,
}).partial({ contentUnitId: true, language: true, kind: true, locked: true });
export type TitleAliasCreate = z.infer<typeof TitleAliasCreate>;
export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial();
export type TitleAliasUpdate = z.infer<typeof TitleAliasUpdate>;
+166 -2
View File
@@ -1,5 +1,9 @@
import { z } from "zod";
import { Timestamp } from "./common";
import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent";
import { Id, NonNegInt, Timestamp } from "./common";
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution";
import { OrganizationStatus } from "./organization";
import { FeedHealth } from "./sources";
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
export const EventEnvelope = z.object({
@@ -15,10 +19,30 @@ export type EventEnvelope<P = unknown> = {
payload: P;
};
/** 已知事件类型;业务事件后续按 `域.动作` 追加 */
/** 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema */
export const EventType = {
heartbeat: "system.heartbeat",
resync: "system.resync",
taskCreated: "task.created",
taskStatusChanged: "task.status_changed",
downloadSubmitted: "download.submitted",
downloadProgress: "download.progress",
downloadStatusChanged: "download.status_changed",
downloadThreshold: "download.threshold",
organizationChanged: "organization.changed",
agentRunStatus: "agent.run_status",
agentTextDelta: "agent.text_delta",
agentToolCall: "agent.tool_call",
feedFetched: "feed.fetched",
feedHealthChanged: "feed.health_changed",
decisionCreated: "decision.created",
decisionUpdated: "decision.updated",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
@@ -26,3 +50,143 @@ export type EventType = (typeof EventType)[keyof typeof EventType];
export const HeartbeatPayload = z.object({
role: z.string(),
});
/** 服务端环形缓冲不足以补读时发出,客户端应整页刷新 */
export const ResyncPayload = z.object({ reason: z.string() });
export const TaskCreatedPayload = z.object({
taskId: Id,
kind: TaskKind,
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
});
export const TaskStatusChangedPayload = z.object({
taskId: Id,
kind: TaskKind,
from: TaskStatus.nullable(),
to: TaskStatus,
error: z.string().nullable(),
});
export const DownloadSubmittedPayload = z.object({
downloadTaskId: Id,
instanceId: Id,
torrentHash: z.string(),
planId: Id.nullable(),
name: z.string(),
});
export const DownloadProgressPayload = z.object({
downloadTaskId: Id,
progress: z.number().min(0).max(1),
downloadedBytes: NonNegInt,
sizeBytes: NonNegInt.nullable(),
});
export const DownloadStatusChangedPayload = z.object({
downloadTaskId: Id,
from: DownloadStatus.nullable(),
to: DownloadStatus,
pauseReason: PauseReason.nullable(),
});
/** 缓存卷越过硬阈值(tripped)或回到恢复水位(recovered) */
export const DownloadThresholdPayload = z.object({
volumeKey: z.string(),
state: z.enum(["tripped", "recovered", "unknown"]),
usedPercent: z.number().min(0).max(100).nullable(),
pausedTaskIds: z.array(Id),
});
/** 整理记录新增或状态变化;changeSeq 与公开 API 的游标一致 */
export const OrganizationChangedPayload = z.object({
recordId: Id,
status: OrganizationStatus,
changeSeq: z.string().regex(/^\d+$/),
mediaEntityId: Id.nullable(),
});
export const AgentRunStatusPayload = z.object({
runId: Id,
status: AgentRunStatus,
summary: z.string().nullable(),
});
/** Agent 文本增量:runSeq 为该 run 内序号,配合 agent_run.last_event_seq 断点续读 */
export const AgentTextDeltaPayload = z.object({
runId: Id,
runSeq: NonNegInt,
delta: z.string(),
});
export const AgentToolCallPayload = z.object({
runId: Id,
runSeq: NonNegInt,
capabilityId: z.string(),
phase: z.enum(["started", "succeeded", "failed", "denied"]),
/** 已脱敏的摘要,不含凭据/私有 URL */
summary: z.string(),
});
export const FeedFetchedPayload = z.object({
feedId: Id,
newItems: NonNegInt,
updatedItems: NonNegInt,
notModified: z.boolean(),
});
export const FeedHealthChangedPayload = z.object({
feedId: Id,
from: FeedHealth.nullable(),
to: FeedHealth,
reason: z.string().nullable(),
});
export const DecisionCreatedPayload = z.object({
decisionId: Id,
kind: DecisionKind,
severity: Severity,
title: z.string(),
});
export const DecisionUpdatedPayload = z.object({
decisionId: Id,
status: DecisionStatus,
});
/** 事件类型 → payload schema */
export const EVENT_PAYLOADS = {
"system.heartbeat": HeartbeatPayload,
"system.resync": ResyncPayload,
"task.created": TaskCreatedPayload,
"task.status_changed": TaskStatusChangedPayload,
"download.submitted": DownloadSubmittedPayload,
"download.progress": DownloadProgressPayload,
"download.status_changed": DownloadStatusChangedPayload,
"download.threshold": DownloadThresholdPayload,
"organization.changed": OrganizationChangedPayload,
"agent.run_status": AgentRunStatusPayload,
"agent.text_delta": AgentTextDeltaPayload,
"agent.tool_call": AgentToolCallPayload,
"feed.fetched": FeedFetchedPayload,
"feed.health_changed": FeedHealthChangedPayload,
"decision.created": DecisionCreatedPayload,
"decision.updated": DecisionUpdatedPayload,
} as const satisfies Record<EventType, z.ZodType>;
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
/** 已知事件的判别联合:`{ type, payload }` */
export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload<T> } }[EventType];
/** 已知事件信封的 schema(判别联合) */
export const TypedEventEnvelope = z.discriminatedUnion(
"type",
Object.entries(EVENT_PAYLOADS).map(([type, payload]) =>
z.object({ seq: z.number().int().nonnegative(), at: Timestamp, type: z.literal(type), payload }),
) as unknown as [z.ZodObject, ...z.ZodObject[]],
);
export function isEventType(type: string): type is EventType {
return Object.hasOwn(EVENT_PAYLOADS, type);
}
/** 校验一个事件 payload;不合法返回 null */
export function parseEventPayload<T extends EventType>(type: T, payload: unknown): EventPayload<T> | null {
const schema: z.ZodType = EVENT_PAYLOADS[type];
const r = schema.safeParse(payload);
return r.success ? (r.data as EventPayload<T>) : null;
}
+292
View File
@@ -0,0 +1,292 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
import { AcquisitionPriority } from "./policy";
// 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002)
/** 持久任务类型(任务书 §3.1-8)+ Agent 有界任务 */
export const TaskKind = z.enum([
"ingestFeed",
"resolveObservation",
"evaluateAcquisition",
"submitDownload",
"verifyAndImport",
"refreshMetadata",
"reconcileLibrary",
"cleanupDownloadCache",
"resolveDecision",
"agentRun",
]);
export type TaskKind = z.infer<typeof TaskKind>;
export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]);
export type TaskStatus = z.infer<typeof TaskStatus>;
export const Task = EntityBase.extend({
kind: TaskKind,
handlerVersion: z.number().int().min(1),
idempotencyKey: z.string().min(1),
status: TaskStatus,
/** 数值越小越先执行 */
priority: z.number().int(),
input: JsonObject,
output: JsonObject.nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
parentTaskId: Id.nullable(),
engineRunId: z.string().nullable(),
attempts: NonNegInt,
maxAttempts: z.number().int().min(1),
nextRunAt: Timestamp,
waitReason: z.string().nullable(),
cancelRequestedAt: Timestamp.nullable(),
cancelReason: z.string().nullable(),
lastError: JsonObject.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
triggeredBy: z.string().nullable(),
});
export type Task = z.infer<typeof Task>;
/** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */
export const TaskCreate = Task.pick({
kind: true,
handlerVersion: true,
idempotencyKey: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
}).partial({
handlerVersion: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
});
export type TaskCreate = z.infer<typeof TaskCreate>;
export const TaskUpdate = Task.pick({
status: true,
output: true,
engineRunId: true,
attempts: true,
nextRunAt: true,
waitReason: true,
lastError: true,
startedAt: true,
finishedAt: true,
}).partial();
export type TaskUpdate = z.infer<typeof TaskUpdate>;
/** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */
export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) });
export type TaskCancelRequest = z.infer<typeof TaskCancelRequest>;
export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]);
export type PlanPurpose = z.infer<typeof PlanPurpose>;
export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]);
export type PlanStatus = z.infer<typeof PlanStatus>;
/** 下载计划:与 Slot 预留、预算预留同事务创建;一个 Slot 同时只能有一个 reserved/submitted 计划 */
export const AcquisitionPlan = EntityBase.extend({
slotId: Id,
sourceReleaseId: Id,
taskId: Id.nullable(),
purpose: PlanPurpose,
status: PlanStatus,
priority: AcquisitionPriority,
/** evaluateAcquisition 的确定性评估结果与拒绝/接受理由 */
evaluation: JsonObject,
reservedBytes: NonNegInt,
budgetReservation: JsonObject,
payloadHash: z.string().min(1),
expectedRevision: z.number().int().min(1),
idempotencyKey: z.string().min(1),
expiresAt: Timestamp.nullable(),
statusReason: z.string().nullable(),
});
export type AcquisitionPlan = z.infer<typeof AcquisitionPlan>;
export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({
taskId: true,
purpose: true,
priority: true,
evaluation: true,
reservedBytes: true,
budgetReservation: true,
expectedRevision: true,
expiresAt: true,
statusReason: true,
});
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;
export const DownloadOrigin = z.enum(["submitted", "adopted"]);
export type DownloadOrigin = z.infer<typeof DownloadOrigin>;
export const DownloadStatus = z.enum([
"queued",
"submitting",
"downloading",
"paused",
"stalled",
"completed",
"importing",
"imported",
"failed",
"removed",
"missing",
]);
export type DownloadStatus = z.infer<typeof DownloadStatus>;
/** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */
export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]);
export type PauseReason = z.infer<typeof PauseReason>;
export const TorrentHash = z.string().regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/);
export const DownloadTask = EntityBase.extend({
instanceId: Id,
/** 该下载器实例内的 torrent 标识(v1 hash 或 v2 hash,小写十六进制) */
torrentHash: TorrentHash,
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
acquisitionPlanId: Id.nullable(),
sourceReleaseId: Id.nullable(),
name: z.string(),
savePath: z.string().nullable(),
contentPath: z.string().nullable(),
category: z.string().nullable(),
tags: z.array(z.string()),
/** 是否在管理标签范围内;标签移除后暂停后续自动动作 */
managed: z.boolean(),
origin: DownloadOrigin,
status: DownloadStatus,
pauseReason: PauseReason.nullable(),
rawState: z.string().nullable(),
progress: z.number().min(0).max(1),
sizeBytes: NonNegInt.nullable(),
downloadedBytes: NonNegInt,
remainingBytes: NonNegInt.nullable(),
ratio: z.number().nullable(),
seedingTimeSec: NonNegInt.nullable(),
completedAt: Timestamp.nullable(),
lastProgressAt: Timestamp.nullable(),
/** 无进展起点:只记录并汇入待处理,不自动换源(REQ §8.4.2) */
noProgressSince: Timestamp.nullable(),
lastSyncedAt: Timestamp.nullable(),
nextCheckAt: Timestamp.nullable(),
removedAt: Timestamp.nullable(),
fileList: z.array(z.object({ path: z.string(), size: NonNegInt, progress: z.number().optional() })).nullable(),
lastError: JsonObject.nullable(),
});
export type DownloadTask = z.infer<typeof DownloadTask>;
export const DownloadTaskCreate = DownloadTask.pick({
instanceId: true,
torrentHash: true,
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
name: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
}).partial({
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
});
export type DownloadTaskCreate = z.infer<typeof DownloadTaskCreate>;
export const DownloadTaskUpdate = DownloadTask.omit({
...SERVER_MANAGED,
instanceId: true,
torrentHash: true,
}).partial();
export type DownloadTaskUpdate = z.infer<typeof DownloadTaskUpdate>;
export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]);
export type OperationPhase = z.infer<typeof OperationPhase>;
export const OperationKind = z.enum([
"copy",
"move",
"rename",
"mkdir",
"hardlink",
"delete_source",
"delete_target",
"verify",
"register",
"qb_pause",
"qb_resume",
"qb_remove",
"qb_set_tags",
"other",
]);
export type OperationKind = z.infer<typeof OperationKind>;
export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]);
export type OperationStatus = z.infer<typeof OperationStatus>;
/** 副作用账本:plan → attempt(started, attemptToken) → result;提交时以 attemptToken 做 fencing */
export const AssetOperation = EntityBase.extend({
operationId: z.string().min(1),
taskId: Id.nullable(),
organizationRecordId: Id.nullable(),
planFingerprint: z.string().nullable(),
ordinal: z.number().int().nonnegative(),
phase: OperationPhase,
kind: OperationKind,
status: OperationStatus,
intent: JsonObject,
attemptToken: Id.nullable(),
attempts: NonNegInt,
result: JsonObject.nullable(),
error: JsonObject.nullable(),
mediaAssetId: Id.nullable(),
downloadTaskId: Id.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type AssetOperation = z.infer<typeof AssetOperation>;
export const AssetOperationCreate = AssetOperation.pick({
operationId: true,
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
phase: true,
kind: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
}).partial({
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
});
export type AssetOperationCreate = z.infer<typeof AssetOperationCreate>;
export const AssetOperationUpdate = AssetOperation.pick({
status: true,
attemptToken: true,
attempts: true,
result: true,
error: true,
mediaAssetId: true,
startedAt: true,
finishedAt: true,
}).partial();
export type AssetOperationUpdate = z.infer<typeof AssetOperationUpdate>;
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject, Timestamp } from "./common";
// 身份域:外部身份映射与修订(REQ §7.2)
// provider + entityType + externalId 对 accepted 唯一;TMDB movie:123 与 tv:123 是两个身份。
export const IdentityStatus = z.enum(["accepted", "proposed", "conflicted", "retired"]);
export type IdentityStatus = z.infer<typeof IdentityStatus>;
export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]);
export type ExternalEntityType = z.infer<typeof ExternalEntityType>;
/** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */
export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]);
export type EvidenceLevel = z.infer<typeof EvidenceLevel>;
export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]);
export type IdentityVerifyStatus = z.infer<typeof IdentityVerifyStatus>;
export const IdentityProvider = z.string().regex(/^[a-z][a-z0-9_]*$/);
export const ExternalIdentity = EntityBase.extend({
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
provider: IdentityProvider,
entityType: ExternalEntityType,
externalId: z.string().min(1),
status: IdentityStatus,
evidenceLevel: EvidenceLevel,
evidence: JsonObject,
/** 人工锁:自动匹配/刷新不得改动 */
locked: z.boolean(),
revision: z.number().int().min(1),
validFrom: Timestamp.nullable(),
validUntil: Timestamp.nullable(),
verifiedAt: Timestamp.nullable(),
/** 404/超时/鉴权失败只记录到这里,不是删除映射的依据 */
verifyStatus: IdentityVerifyStatus,
/** 附属字段,如 TMDB episode_group */
extra: JsonObject,
});
export type ExternalIdentity = z.infer<typeof ExternalIdentity>;
const oneTarget = (v: { mediaEntityId?: string | null; contentUnitId?: string | null }) =>
Number(!!v.mediaEntityId) + Number(!!v.contentUnitId) === 1;
export const ExternalIdentityCreate = ExternalIdentity.pick({
mediaEntityId: true,
contentUnitId: true,
provider: true,
entityType: true,
externalId: true,
status: true,
evidenceLevel: true,
evidence: true,
locked: true,
validFrom: true,
validUntil: true,
extra: true,
})
.partial({
mediaEntityId: true,
contentUnitId: true,
status: true,
evidenceLevel: true,
evidence: true,
locked: true,
validFrom: true,
validUntil: true,
extra: true,
})
.refine(oneTarget, { message: "mediaEntityId 与 contentUnitId 必须且只能给一个" });
export type ExternalIdentityCreate = z.infer<typeof ExternalIdentityCreate>;
/** 变更一律生成 identity_revision;expectedRevision 用于乐观并发 */
export const ExternalIdentityUpdate = z.object({
expectedRevision: z.number().int().min(1),
status: IdentityStatus.optional(),
evidenceLevel: EvidenceLevel.optional(),
evidence: JsonObject.optional(),
locked: z.boolean().optional(),
validUntil: Timestamp.nullable().optional(),
extra: JsonObject.optional(),
reason: z.string().max(2000).optional(),
});
export type ExternalIdentityUpdate = z.infer<typeof ExternalIdentityUpdate>;
export const IdentityRevisionAction = z.enum([
"created",
"status_changed",
"relinked",
"evidence_updated",
"locked",
"unlocked",
]);
export type IdentityRevisionAction = z.infer<typeof IdentityRevisionAction>;
/** 身份修订历史:只追加,不修改 */
export const IdentityRevision = EntityBase.extend({
externalIdentityId: Id,
revision: z.number().int().min(1),
action: IdentityRevisionAction,
fromStatus: IdentityStatus.nullable(),
toStatus: IdentityStatus.nullable(),
before: JsonObject.nullable(),
after: JsonObject,
actorType: ActorType,
actorId: z.string().nullable(),
reason: z.string().nullable(),
});
export type IdentityRevision = z.infer<typeof IdentityRevision>;
/** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */
export const IdentityRevisionCreate = IdentityRevision.pick({
externalIdentityId: true,
revision: true,
action: true,
fromStatus: true,
toStatus: true,
before: true,
after: true,
actorType: true,
actorId: true,
reason: true,
}).partial({ fromStatus: true, toStatus: true, before: true, actorId: true, reason: true });
export type IdentityRevisionCreate = z.infer<typeof IdentityRevisionCreate>;
+13
View File
@@ -1,3 +1,16 @@
export * from "./agent";
export * from "./api";
export * from "./assets";
export * from "./capabilities-io";
export * from "./capability";
export * from "./common";
export * from "./content";
export * from "./events";
export * from "./execution";
export * from "./identity";
export * from "./instances";
export * from "./ops";
export * from "./organization";
export * from "./policy";
export * from "./settings";
export * from "./sources";
+161
View File
@@ -0,0 +1,161 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5)
// 凭证只以 secretRef 暴露;创建/更新时的 credential 是只写明文,服务端加密为 secret 后丢弃。
export const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]);
export type InstanceHealth = z.infer<typeof InstanceHealth>;
export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]);
export type LibraryMediaKind = z.infer<typeof LibraryMediaKind>;
/** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */
export const ManagementMode = z.enum(["register_only", "managed"]);
export type ManagementMode = z.infer<typeof ManagementMode>;
export const LibraryRoot = EntityBase.extend({
name: z.string().min(1),
mediaKind: LibraryMediaKind,
/** 执行进程可访问的绝对路径 */
localPath: z.string().startsWith("/"),
managementMode: ManagementMode,
enabled: z.boolean(),
sentinelFile: z.string().nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
healthDetail: JsonObject,
notes: z.string().nullable(),
});
export type LibraryRoot = z.infer<typeof LibraryRoot>;
export const LibraryRootCreate = LibraryRoot.pick({
name: true,
mediaKind: true,
localPath: true,
managementMode: true,
enabled: true,
sentinelFile: true,
notes: true,
}).partial({ mediaKind: true, managementMode: true, enabled: true, sentinelFile: true, notes: true });
export type LibraryRootCreate = z.infer<typeof LibraryRootCreate>;
export const LibraryRootUpdate = LibraryRootCreate.partial();
export type LibraryRootUpdate = z.infer<typeof LibraryRootUpdate>;
/** 只写凭证:明文仅在请求中出现,绝不回显 */
const Credential = z.string().min(1).max(4096);
export const DownloaderKind = z.enum(["qbittorrent"]);
export type DownloaderKind = z.infer<typeof DownloaderKind>;
export const DownloaderInstance = EntityBase.extend({
name: z.string().min(1),
kind: DownloaderKind,
baseUrl: z.url(),
username: z.string().nullable(),
secretRef: Id.nullable(),
/** 管理标签名(REQ §8.4) */
managedTag: z.string().min(1),
/** 共享缓存卷标识:多 qB 共用同一卷时按此汇总水位 */
cacheVolumeKey: z.string().nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type DownloaderInstance = z.infer<typeof DownloaderInstance>;
export const DownloaderInstanceCreate = DownloaderInstance.pick({
name: true,
kind: true,
baseUrl: true,
username: true,
managedTag: true,
cacheVolumeKey: true,
enabled: true,
settings: true,
})
.partial({ kind: true, username: true, managedTag: true, cacheVolumeKey: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial();
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
export const MediaServerKind = z.enum(["emby", "jellyfin"]);
export type MediaServerKind = z.infer<typeof MediaServerKind>;
export const MediaServerInstance = EntityBase.extend({
name: z.string().min(1),
kind: MediaServerKind,
baseUrl: z.url(),
userId: z.string().nullable(),
secretRef: Id.nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type MediaServerInstance = z.infer<typeof MediaServerInstance>;
export const MediaServerInstanceCreate = MediaServerInstance.pick({
name: true,
kind: true,
baseUrl: true,
userId: true,
enabled: true,
settings: true,
})
.partial({ kind: true, userId: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type MediaServerInstanceCreate = z.infer<typeof MediaServerInstanceCreate>;
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial();
export type MediaServerInstanceUpdate = z.infer<typeof MediaServerInstanceUpdate>;
export const PathViewKind = z.enum(["downloader", "media_server"]);
export type PathViewKind = z.infer<typeof PathViewKind>;
export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]);
export type PathMappingVerifyStatus = z.infer<typeof PathMappingVerifyStatus>;
/** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(REQ §12.5) */
export const PathMapping = EntityBase.extend({
viewKind: PathViewKind,
downloaderInstanceId: Id.nullable(),
mediaServerInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
remotePrefix: z.string().min(1),
localPrefix: z.string().startsWith("/"),
priority: z.number().int(),
enabled: z.boolean(),
verifyStatus: PathMappingVerifyStatus,
verifiedAt: Timestamp.nullable(),
verifyDetail: JsonObject,
});
export type PathMapping = z.infer<typeof PathMapping>;
const pathMappingOwnerOk = (v: {
viewKind?: PathViewKind;
downloaderInstanceId?: string | null;
mediaServerInstanceId?: string | null;
}) =>
v.viewKind === undefined ||
(v.viewKind === "downloader"
? !!v.downloaderInstanceId && !v.mediaServerInstanceId
: !!v.mediaServerInstanceId && !v.downloaderInstanceId);
const PathMappingInput = PathMapping.omit({
...SERVER_MANAGED,
verifyStatus: true,
verifiedAt: true,
verifyDetail: true,
});
export const PathMappingCreate = PathMappingInput.partial({
downloaderInstanceId: true,
mediaServerInstanceId: true,
libraryRootId: true,
priority: true,
enabled: true,
}).refine(pathMappingOwnerOk, { message: "viewKind 与实例 ID 不匹配" });
export type PathMappingCreate = z.infer<typeof PathMappingCreate>;
export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, {
message: "viewKind 与实例 ID 不匹配",
});
export type PathMappingUpdate = z.infer<typeof PathMappingUpdate>;
+194
View File
@@ -0,0 +1,194 @@
import { z } from "zod";
import { ActorType, ApiScope, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 运维域:校验运行、媒体服务器库存映射、API Key、审计、outbox、凭证(REQ §8.1、§7.4、§13.2)
export const ReconcileKind = z.enum(["identity", "metadata", "asset", "acquisition", "server"]);
export type ReconcileKind = z.infer<typeof ReconcileKind>;
/** read_only:恢复后默认只读对账模式(REQ §8.5) */
export const ReconcileMode = z.enum(["normal", "read_only"]);
export type ReconcileMode = z.infer<typeof ReconcileMode>;
export const ReconcileStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type ReconcileStatus = z.infer<typeof ReconcileStatus>;
export const ReconcileRun = EntityBase.extend({
kind: ReconcileKind,
mode: ReconcileMode,
status: ReconcileStatus,
scope: JsonObject,
/** 中断续扫位置 */
resumeCursor: JsonObject,
stats: JsonObject,
taskId: Id.nullable(),
error: JsonObject.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type ReconcileRun = z.infer<typeof ReconcileRun>;
export const ReconcileRunCreate = ReconcileRun.pick({ kind: true, mode: true, scope: true }).partial({
mode: true,
scope: true,
});
export type ReconcileRunCreate = z.infer<typeof ReconcileRunCreate>;
export const ReconcileRunUpdate = ReconcileRun.pick({
status: true,
resumeCursor: true,
stats: true,
taskId: true,
error: true,
startedAt: true,
finishedAt: true,
}).partial();
export type ReconcileRunUpdate = z.infer<typeof ReconcileRunUpdate>;
export const ServerItemType = z.enum(["movie", "series", "season", "episode", "other"]);
export type ServerItemType = z.infer<typeof ServerItemType>;
/** 未知 ≠ 缺失:stale/unmatched 不能推出"没有资源" */
export const ServerMatchStatus = z.enum(["matched", "ambiguous", "unmatched", "stale"]);
export type ServerMatchStatus = z.infer<typeof ServerMatchStatus>;
/** Emby 兼容库存条目与本地对象的映射(辅助证据,不构成文件纳管授权) */
export const ServerMapping = EntityBase.extend({
mediaServerInstanceId: Id,
serverItemId: z.string().min(1),
itemType: ServerItemType,
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
serverPath: z.string().nullable(),
pathMappingId: Id.nullable(),
matchStatus: ServerMatchStatus,
versionInfo: JsonObject,
evidence: JsonObject,
observedAt: Timestamp,
});
export type ServerMapping = z.infer<typeof ServerMapping>;
export const ServerMappingCreate = ServerMapping.omit(SERVER_MANAGED).partial({
mediaEntityId: true,
contentUnitId: true,
mediaAssetId: true,
serverPath: true,
pathMappingId: true,
matchStatus: true,
versionInfo: true,
evidence: true,
observedAt: true,
});
export type ServerMappingCreate = z.infer<typeof ServerMappingCreate>;
export const ServerMappingUpdate = ServerMappingCreate.omit({
mediaServerInstanceId: true,
serverItemId: true,
}).partial();
export type ServerMappingUpdate = z.infer<typeof ServerMappingUpdate>;
/** API Key(不含 key_hash) */
export const ApiKey = EntityBase.extend({
name: z.string().min(1),
scopes: z.array(ApiScope),
revokedAt: Timestamp.nullable(),
lastUsedAt: Timestamp.nullable(),
});
export type ApiKey = z.infer<typeof ApiKey>;
export const ApiKeyCreate = z.object({
name: z.string().min(1).max(80),
scopes: z.array(ApiScope).min(1),
});
export type ApiKeyCreate = z.infer<typeof ApiKeyCreate>;
/** 创建响应:明文 key 只在此返回一次 */
export const ApiKeyCreated = z.object({ apiKey: ApiKey, plaintextKey: z.string().min(16) });
export type ApiKeyCreated = z.infer<typeof ApiKeyCreated>;
export const ApiKeyUpdate = z.object({
name: z.string().min(1).max(80).optional(),
scopes: z.array(ApiScope).min(1).optional(),
revoked: z.literal(true).optional(),
});
export type ApiKeyUpdate = z.infer<typeof ApiKeyUpdate>;
/** 审计事件(只追加) */
export const AuditEvent = z.object({
id: Id,
at: Timestamp,
actorType: z.string(),
actorId: z.string().nullable(),
action: z.string().min(1),
targetType: z.string().nullable(),
targetId: z.string().nullable(),
payload: JsonObject,
traceId: z.string().nullable(),
});
export type AuditEvent = z.infer<typeof AuditEvent>;
export const AuditEventCreate = AuditEvent.omit({ id: true, at: true })
.partial({ actorId: true, targetType: true, targetId: true, payload: true, traceId: true })
.extend({ actorType: ActorType });
export type AuditEventCreate = z.infer<typeof AuditEventCreate>;
export const OutboxStatus = z.enum(["pending", "dispatched", "failed", "dead"]);
export type OutboxStatus = z.infer<typeof OutboxStatus>;
/** outbox:业务事务内写入,分发器轮询 pending 并以 idempotencyKey 入队(ADR-002 §2.1) */
export const OutboxEvent = z.object({
id: Id,
at: Timestamp,
type: z.string().min(1),
payload: JsonObject,
publishedAt: Timestamp.nullable(),
idempotencyKey: z.string().nullable(),
status: OutboxStatus,
attempts: NonNegInt,
nextAttemptAt: Timestamp,
lastError: JsonObject.nullable(),
});
export type OutboxEvent = z.infer<typeof OutboxEvent>;
export const OutboxEventCreate = OutboxEvent.pick({
type: true,
payload: true,
idempotencyKey: true,
nextAttemptAt: true,
}).partial({
payload: true,
idempotencyKey: true,
nextAttemptAt: true,
});
export type OutboxEventCreate = z.infer<typeof OutboxEventCreate>;
export const OutboxEventUpdate = OutboxEvent.pick({
status: true,
attempts: true,
nextAttemptAt: true,
lastError: true,
publishedAt: true,
}).partial();
export type OutboxEventUpdate = z.infer<typeof OutboxEventUpdate>;
export const SecretPurpose = z.enum([
"site_cookie",
"site_passkey",
"site_api_key",
"site_password",
"feed_url",
"downloader_password",
"media_server_api_key",
"metadata_api_key",
"llm_api_key",
"other",
]);
export type SecretPurpose = z.infer<typeof SecretPurpose>;
/** 加密凭证的元数据视图:密文/nonce 永不出 API */
export const Secret = EntityBase.extend({
name: z.string().min(1),
purpose: SecretPurpose,
algorithm: z.string(),
kdf: z.string(),
kdfParams: JsonObject,
keyVersion: z.number().int().min(1),
rotatedAt: Timestamp.nullable(),
});
export type Secret = z.infer<typeof Secret>;
/** plaintext 只写;服务端用 SECRET_MASTER_KEY 派生密钥加密后存 ciphertext/nonce */
export const SecretCreate = z.object({
name: z.string().min(1).max(120),
purpose: SecretPurpose,
plaintext: z.string().min(1).max(16384),
});
export type SecretCreate = z.infer<typeof SecretCreate>;
export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) });
export type SecretUpdate = z.infer<typeof SecretUpdate>;
+288
View File
@@ -0,0 +1,288 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common";
import { MediaKind } from "./content";
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/** Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有) */
export const OrganizationAction = z.enum(["copy", "move", "rename", "relocate", "hardlink"]);
export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
export const SourcePathView = z.enum(["downloader", "local", "library"]);
export type SourcePathView = z.infer<typeof SourcePathView>;
export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]);
export type FileRole = z.infer<typeof FileRole>;
export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]);
export type FileResultStatus = z.infer<typeof FileResultStatus>;
/** 稳定变更游标:bigint 序号的十进制字符串 */
export const ChangeSeq = z.number().int().nonnegative();
export const ChangeCursor = z.string().regex(/^\d{1,16}$/);
export type ChangeCursor = z.infer<typeof ChangeCursor>;
// ── 内部行表示(与 organization_record / organization_file_result 列一一对应,不含 change_xid) ──
export const OrganizationRecord = EntityBase.extend({
taskId: Id.nullable(),
trigger: OrganizationTrigger,
actorType: ActorType,
actorId: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
downloadTaskId: Id.nullable(),
sourceDownloaderInstanceId: Id.nullable(),
sourcePathView: SourcePathView,
sourcePath: z.string().nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
action: OrganizationAction,
status: OrganizationStatus,
reason: z.string().nullable(),
error: JsonObject.nullable(),
/** 冻结的整理计划 */
plan: JsonObject,
planFingerprint: z.string().nullable(),
fileTotal: NonNegInt,
fileSucceeded: NonNegInt,
fileFailed: NonNegInt,
fileSkipped: NonNegInt,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
changeSeq: ChangeSeq,
});
export type OrganizationRecord = z.infer<typeof OrganizationRecord>;
export const OrganizationRecordCreate = OrganizationRecord.pick({
taskId: true,
trigger: true,
actorType: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
action: true,
plan: true,
planFingerprint: true,
}).partial({
taskId: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
plan: true,
planFingerprint: true,
});
export type OrganizationRecordCreate = z.infer<typeof OrganizationRecordCreate>;
export const OrganizationRecordUpdate = OrganizationRecord.pick({
status: true,
reason: true,
error: true,
targetPath: true,
fileTotal: true,
fileSucceeded: true,
fileFailed: true,
fileSkipped: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationRecordUpdate = z.infer<typeof OrganizationRecordUpdate>;
export const OrganizationFileResult = EntityBase.extend({
organizationRecordId: Id,
ordinal: z.number().int().nonnegative(),
assetOperationId: Id.nullable(),
mediaAssetId: Id.nullable(),
contentUnitId: Id.nullable(),
role: FileRole,
mode: OrganizationAction,
sourcePath: z.string().min(1),
sourceSizeBytes: NonNegInt.nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
status: FileResultStatus,
skipReason: z.string().nullable(),
error: JsonObject.nullable(),
checksum: z.string().nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type OrganizationFileResult = z.infer<typeof OrganizationFileResult>;
export const OrganizationFileResultCreate = OrganizationFileResult.pick({
organizationRecordId: true,
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
mode: true,
sourcePath: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
}).partial({
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
});
export type OrganizationFileResultCreate = z.infer<typeof OrganizationFileResultCreate>;
export const OrganizationFileResultUpdate = OrganizationFileResult.pick({
assetOperationId: true,
mediaAssetId: true,
targetPath: true,
status: true,
skipReason: true,
error: true,
checksum: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationFileResultUpdate = z.infer<typeof OrganizationFileResultUpdate>;
// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ──
/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */
export const PublicReason = z.object({
code: z.string().min(1),
message: z.string(),
});
export type PublicReason = z.infer<typeof PublicReason>;
/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */
export const PublicPathRef = z.object({
path: z.string().nullable(),
view: z.enum(["downloader", "local", "library"]),
downloaderInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
});
export type PublicPathRef = z.infer<typeof PublicPathRef>;
export const PublicOrganizationFile = z.object({
id: Id,
ordinal: z.number().int().nonnegative(),
role: FileRole,
mode: OrganizationAction,
status: FileResultStatus,
source: PublicPathRef,
target: PublicPathRef,
sizeBytes: NonNegInt.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
operationId: Id.nullable(),
skipReason: PublicReason.nullable(),
failure: PublicReason.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type PublicOrganizationFile = z.infer<typeof PublicOrganizationFile>;
export const PublicOrganizationRecord = z.object({
/** 稳定记录 ID */
id: Id,
taskId: Id.nullable(),
/** 相关资产操作账本 ID */
operationIds: z.array(Id),
media: z
.object({
entityId: Id,
kind: MediaKind,
title: z.string(),
year: z.number().int().nullable(),
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nullable(),
episodeNumber: z.number().int().nullable(),
/** 已确认的外部身份,如 tmdb:movie:693134 */
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
})
.nullable(),
trigger: OrganizationTrigger,
/** 手动 / 自动来源(agent 视为自动) */
origin: z.enum(["manual", "auto"]),
actor: z.object({ type: ActorType, id: z.string().nullable() }),
action: OrganizationAction,
status: OrganizationStatus,
source: PublicPathRef,
target: PublicPathRef,
reason: PublicReason.nullable(),
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
/** includeFiles=false 时为 null */
files: z.array(PublicOrganizationFile).nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
/** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */
changeSeq: ChangeCursor,
});
export type PublicOrganizationRecord = z.infer<typeof PublicOrganizationRecord>;
/** 增量读取请求:after 为上次响应的 nextCursor(首读省略即从头) */
export const OrganizationChangesQuery = z.object({
after: ChangeCursor.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
includeFiles: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).default(true),
status: OrganizationStatus.optional(),
mediaEntityId: Id.optional(),
});
export type OrganizationChangesQuery = z.infer<typeof OrganizationChangesQuery>;
/**
* 增量读取响应:items 按 changeSeq 升序;nextCursor 总是返回(无新数据时等于请求的 after),
* hasMore=true 表示应立即继续拉取。同一记录状态更新会以更大的 changeSeq 再次出现。
*/
export const OrganizationChangesResponse = z.object({
items: z.array(PublicOrganizationRecord),
nextCursor: ChangeCursor,
hasMore: z.boolean(),
});
export type OrganizationChangesResponse = z.infer<typeof OrganizationChangesResponse>;
/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */
export const OrganizationListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
status: OrganizationStatus.optional(),
trigger: OrganizationTrigger.optional(),
mediaEntityId: Id.optional(),
q: z.string().max(200).optional(),
});
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
export const OrganizeRequest = z
.object({
downloadTaskId: Id.optional(),
/** 执行进程可访问的源路径(与 downloadTaskId 二选一) */
sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(),
mediaEntityId: Id.optional(),
contentUnitId: Id.optional(),
action: z.enum(["copy", "move"]),
targetLibraryRootId: Id.optional(),
/** true 只生成计划(organize.plan),不执行 */
dryRun: z.boolean().default(false),
})
.refine((v) => !!v.downloadTaskId !== !!v.sourcePaths, { message: "downloadTaskId 与 sourcePaths 必须二选一" });
export type OrganizeRequest = z.infer<typeof OrganizeRequest>;
export const OrganizeAccepted = z.object({
recordId: Id.nullable(),
taskId: Id,
planId: Id.nullable(),
});
export type OrganizeAccepted = z.infer<typeof OrganizeAccepted>;
+247
View File
@@ -0,0 +1,247 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 规则域:订阅、策略版本、收录 Slot、偏好标签与抑制记录(REQ §6)
export const PolicyKind = z.enum(["acquisition", "preference", "retention", "cleanup", "instance", "automation"]);
export type PolicyKind = z.infer<typeof PolicyKind>;
export const PolicyStatus = z.enum(["draft", "published", "superseded", "rolled_back", "archived"]);
export type PolicyStatus = z.infer<typeof PolicyStatus>;
/** 策略版本:草案 → 预览 → 授权发布 → 版本化/回滚;同一 policyKey 只有一个 published */
export const PolicyRevision = EntityBase.extend({
policyKey: z.string().min(1),
revision: z.number().int().min(1),
kind: PolicyKind,
status: PolicyStatus,
title: z.string().min(1),
summary: z.string().nullable(),
scope: JsonObject,
/** 结构化规则数据;不是可执行脚本或工作流 DSL */
rules: JsonObject,
preview: JsonObject.nullable(),
basedOnRevisionId: Id.nullable(),
createdBy: z.string().nullable(),
publishedAt: Timestamp.nullable(),
publishedBy: z.string().nullable(),
});
export type PolicyRevision = z.infer<typeof PolicyRevision>;
/** 新建总是草案;revision 由服务端分配 */
export const PolicyRevisionCreate = PolicyRevision.pick({
policyKey: true,
kind: true,
title: true,
summary: true,
scope: true,
rules: true,
basedOnRevisionId: true,
}).partial({ summary: true, scope: true, basedOnRevisionId: true });
export type PolicyRevisionCreate = z.infer<typeof PolicyRevisionCreate>;
/** 只有草案可改内容;发布/回滚走动作端点 */
export const PolicyRevisionUpdate = PolicyRevisionCreate.omit({ policyKey: true, kind: true }).partial();
export type PolicyRevisionUpdate = z.infer<typeof PolicyRevisionUpdate>;
export const SubscriptionMediaKind = z.enum(["movie", "series", "variety"]);
export type SubscriptionMediaKind = z.infer<typeof SubscriptionMediaKind>;
export const SubscriptionMode = z.enum(["once", "continuous"]);
export type SubscriptionMode = z.infer<typeof SubscriptionMode>;
/** 仅本季 / 持续后续季(REQ §6.5) */
export const SeasonScope = z.enum(["current_season", "following_seasons"]);
export type SeasonScope = z.infer<typeof SeasonScope>;
export const SubscriptionOrigin = z.enum(["user", "agent", "policy", "import"]);
export type SubscriptionOrigin = z.infer<typeof SubscriptionOrigin>;
/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */
export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]);
export type AcquisitionPriority = z.infer<typeof AcquisitionPriority>;
export const SubscriptionStatus = z.enum(["active", "paused", "completed", "cancelled"]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;
export const Subscription = EntityBase.extend({
mediaEntityId: Id.nullable(),
seasonUnitId: Id.nullable(),
/** 作品尚未建档时的检索词 */
titleQuery: z.string().nullable(),
mediaKind: SubscriptionMediaKind,
mode: SubscriptionMode,
seasonScope: SeasonScope,
origin: SubscriptionOrigin,
priority: AcquisitionPriority,
status: SubscriptionStatus,
policyRevisionId: Id.nullable(),
params: JsonObject,
statusReason: z.string().nullable(),
lastCheckedAt: Timestamp.nullable(),
createdBy: z.string().nullable(),
});
export type Subscription = z.infer<typeof Subscription>;
export const SubscriptionCreate = Subscription.pick({
mediaEntityId: true,
seasonUnitId: true,
titleQuery: true,
mediaKind: true,
mode: true,
seasonScope: true,
origin: true,
priority: true,
policyRevisionId: true,
params: true,
})
.partial({
mediaEntityId: true,
seasonUnitId: true,
titleQuery: true,
mode: true,
seasonScope: true,
origin: true,
priority: true,
policyRevisionId: true,
params: true,
})
.refine((v) => !!v.mediaEntityId || !!v.titleQuery, { message: "mediaEntityId 与 titleQuery 至少给一个" });
export type SubscriptionCreate = z.infer<typeof SubscriptionCreate>;
export const SubscriptionUpdate = Subscription.pick({
mode: true,
seasonScope: true,
priority: true,
status: true,
policyRevisionId: true,
params: true,
statusReason: true,
}).partial();
export type SubscriptionUpdate = z.infer<typeof SubscriptionUpdate>;
/** Slot 状态(REQ §6.1) */
export const SlotStatus = z.enum([
"empty",
"reserved",
"downloading",
"verifying",
"satisfied",
"upgrade_pending",
"attention",
]);
export type SlotStatus = z.infer<typeof SlotStatus>;
/** 独立收录目标:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件 */
export const AcquisitionSlot = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
subscriptionId: Id.nullable(),
policyRevisionId: Id.nullable(),
/** 用户命名,如 archival / streaming */
name: z.string().min(1).max(64),
status: SlotStatus,
hardConditions: JsonObject,
softPreferences: JsonObject,
fallback: JsonObject,
upgradeThreshold: JsonObject,
completion: JsonObject,
/** 同组 Slot 在 requireDistinctAsset=true 时必须由不同物理资产满足(M2 校验) */
distinctGroup: z.string().nullable(),
requireDistinctAsset: z.boolean(),
currentAssetId: Id.nullable(),
reservedPlanId: Id.nullable(),
statusReason: z.string().nullable(),
lastEvaluatedAt: Timestamp.nullable(),
nextEvaluateAt: Timestamp.nullable(),
revision: z.number().int().min(1),
});
export type AcquisitionSlot = z.infer<typeof AcquisitionSlot>;
export const AcquisitionSlotCreate = AcquisitionSlot.pick({
mediaEntityId: true,
contentUnitId: true,
subscriptionId: true,
policyRevisionId: true,
name: true,
hardConditions: true,
softPreferences: true,
fallback: true,
upgradeThreshold: true,
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
}).partial({
contentUnitId: true,
subscriptionId: true,
policyRevisionId: true,
hardConditions: true,
softPreferences: true,
fallback: true,
upgradeThreshold: true,
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
});
export type AcquisitionSlotCreate = z.infer<typeof AcquisitionSlotCreate>;
/** 规则可改;status/currentAsset/reservedPlan 只由状态机推进,不经 Update */
export const AcquisitionSlotUpdate = AcquisitionSlotCreate.omit({
mediaEntityId: true,
contentUnitId: true,
name: true,
})
.partial()
.extend({ expectedRevision: z.number().int().min(1) });
export type AcquisitionSlotUpdate = z.infer<typeof AcquisitionSlotUpdate>;
/** 偏好类型:soft 影响排序;hard 硬排除;version 版本偏好;target 独立目标配置(REQ §6.7) */
export const PreferenceKind = z.enum(["soft", "hard", "version", "target"]);
export type PreferenceKind = z.infer<typeof PreferenceKind>;
export const PreferenceOrigin = z.enum(["manual", "conversation", "agent_inferred"]);
export type PreferenceOrigin = z.infer<typeof PreferenceOrigin>;
export const PreferenceStatus = z.enum(["active", "disabled", "deleted"]);
export type PreferenceStatus = z.infer<typeof PreferenceStatus>;
export const PreferenceTag = EntityBase.extend({
label: z.string().min(1).max(80),
kind: PreferenceKind,
scope: JsonObject,
rule: JsonObject,
/** 语义指纹(kind+scope+rule 规范化哈希),用于抑制匹配 */
fingerprint: z.string().min(1),
origin: PreferenceOrigin,
description: z.string().nullable(),
status: PreferenceStatus,
revision: z.number().int().min(1),
evidence: JsonObject,
policyRevisionId: Id.nullable(),
});
export type PreferenceTag = z.infer<typeof PreferenceTag>;
/** fingerprint 由服务端计算 */
export const PreferenceTagCreate = PreferenceTag.pick({
label: true,
kind: true,
scope: true,
rule: true,
origin: true,
description: true,
evidence: true,
}).partial({ scope: true, rule: true, origin: true, description: true, evidence: true });
export type PreferenceTagCreate = z.infer<typeof PreferenceTagCreate>;
export const PreferenceTagUpdate = PreferenceTagCreate.omit({ origin: true })
.partial()
.extend({ status: PreferenceStatus.optional() });
export type PreferenceTagUpdate = z.infer<typeof PreferenceTagUpdate>;
/** 手动取消标签留下的抑制记录:Agent 不得据同一旧证据自动加回 */
export const PreferenceSuppression = EntityBase.extend({
preferenceTagId: Id.nullable(),
fingerprint: z.string().min(1),
reason: z.string().nullable(),
evidence: JsonObject,
suppressedBy: z.string().min(1),
active: z.boolean(),
liftedAt: Timestamp.nullable(),
liftedBy: z.string().nullable(),
});
export type PreferenceSuppression = z.infer<typeof PreferenceSuppression>;
export const PreferenceSuppressionCreate = PreferenceSuppression.omit({
...SERVER_MANAGED,
active: true,
liftedAt: true,
liftedBy: true,
suppressedBy: true,
}).partial({ preferenceTagId: true, reason: true, evidence: true });
export type PreferenceSuppressionCreate = z.infer<typeof PreferenceSuppressionCreate>;
/** 唯一允许的更新是解除抑制 */
export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() });
export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUpdate>;
+157
View File
@@ -0,0 +1,157 @@
import { z } from "zod";
import { Id, Timestamp } from "./common";
// 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。
// schema 本身不带 .default()(zod 4 在 partial 里仍会套默认值,会让 PATCH 覆盖现值);
// 缺省值统一放在 DEFAULT_SETTINGS,服务端读取时 `{ ...DEFAULT_SETTINGS[key], ...stored }` 后再 parse。
const Percent = z.number().min(1).max(100);
export const AutomationSettings = z.object({
/** 自动化总开关:关闭后所有自动收录/下载/整理停止,只读查询与手动操作可用 */
enabled: z.boolean(),
/** 恢复后的只读对账模式(REQ §8.5):开启时禁止自动写入 */
readOnlyReconcile: z.boolean(),
});
export type AutomationSettings = z.infer<typeof AutomationSettings>;
const CacheShape = z.object({
/** 缓存卷使用率硬阈值:达到即停止新增下载并暂停纳管下载中任务 */
hardThresholdPercent: Percent,
/** 恢复水位:降到此值以下恢复因阈值暂停的任务 */
resumePercent: Percent,
checkIntervalSec: z.number().int().min(10).max(3600),
/** 缓存清理的目标使用率(低水位) */
cleanupTargetPercent: Percent,
});
/** 下载缓存卷硬阈值与恢复水位(REQ §8.4.1) */
export const CacheSettings = CacheShape.refine((v) => v.resumePercent < v.hardThresholdPercent, {
message: "恢复水位必须低于硬阈值",
}).refine((v) => v.cleanupTargetPercent <= v.resumePercent, { message: "清理目标不得高于恢复水位" });
export type CacheSettings = z.infer<typeof CacheSettings>;
export const OrganizeMode = z.enum(["copy", "move"]);
export type OrganizeMode = z.infer<typeof OrganizeMode>;
export const OrganizeSettings = z.object({
defaultMode: OrganizeMode,
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
allowCrossVolumeMove: z.boolean(),
});
export type OrganizeSettings = z.infer<typeof OrganizeSettings>;
export const RetentionSettings = z.object({
/** Copy 入库后下载源在缓存中至少保留的天数(与 H&R 要求分开判断) */
seedRetentionDays: z.number().int().min(0).max(365),
/** 观察/日志类数据保留天数 */
logRetentionDays: z.number().int().min(1).max(3650),
});
export type RetentionSettings = z.infer<typeof RetentionSettings>;
export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export type ThinkingLevel = z.infer<typeof ThinkingLevel>;
/** Pi Agent 设置(REQ §10.5):上游/模型/思考档位/上下文预算/压缩/子代理;密钥只存 secret 引用 */
export const PiSettings = z.object({
provider: z.string().min(1),
baseUrl: z.url().nullable(),
model: z.string().min(1).nullable(),
thinkingLevel: ThinkingLevel,
/** 上下文预算(token) */
contextBudget: z.number().int().min(4096).max(2_000_000),
compaction: z.object({
enabled: z.boolean(),
/** 达到上下文预算的该比例时触发压缩 */
thresholdRatio: z.number().min(0.3).max(0.95),
}),
subagentsEnabled: z.boolean(),
apiKeySecretRef: Id.nullable(),
});
export type PiSettings = z.infer<typeof PiSettings>;
/** 每日预算与并发(REQ §6.4、§10.5) */
export const BudgetSettings = z.object({
maxNewTitlesPerDay: z.number().int().min(0),
maxDownloadBytesPerDay: z.number().int().min(0),
maxConcurrentDownloads: z.number().int().min(1).max(100),
agentMaxRunsPerDay: z.number().int().min(0),
agentMaxCostUsdPerDay: z.number().min(0),
});
export type BudgetSettings = z.infer<typeof BudgetSettings>;
export const GlobalSettings = z.object({
automation: AutomationSettings,
cache: CacheSettings,
organize: OrganizeSettings,
retention: RetentionSettings,
pi: PiSettings,
budget: BudgetSettings,
});
export type GlobalSettings = z.infer<typeof GlobalSettings>;
/** settings 表的合法 key */
export const SettingsKey = GlobalSettings.keyof();
export type SettingsKey = z.infer<typeof SettingsKey>;
/** 缺省设置(settings 表缺 key 或缺字段时使用) */
export const DEFAULT_SETTINGS: GlobalSettings = {
automation: { enabled: true, readOnlyReconcile: false },
cache: { hardThresholdPercent: 90, resumePercent: 85, checkIntervalSec: 60, cleanupTargetPercent: 80 },
organize: { defaultMode: "copy", allowCrossVolumeMove: true },
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
pi: {
provider: "anthropic",
baseUrl: null,
model: null,
thinkingLevel: "medium",
contextBudget: 128_000,
compaction: { enabled: true, thresholdRatio: 0.8 },
subagentsEnabled: false,
apiKeySecretRef: null,
},
budget: {
maxNewTitlesPerDay: 20,
maxDownloadBytesPerDay: 500 * 1024 ** 3,
maxConcurrentDownloads: 5,
agentMaxRunsPerDay: 50,
agentMaxCostUsdPerDay: 5,
},
};
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
export const GlobalSettingsUpdate = z.object({
automation: AutomationSettings.partial().optional(),
cache: CacheShape.partial().optional(),
organize: OrganizeSettings.partial().optional(),
retention: RetentionSettings.partial().optional(),
pi: PiSettings.omit({ apiKeySecretRef: true })
.partial()
.extend({
compaction: PiSettings.shape.compaction.partial().optional(),
/** 只写:明文上游密钥,服务端存为 secret 并回填 apiKeySecretRef;传 null 表示清除 */
apiKey: z.string().min(1).max(4096).nullable().optional(),
})
.optional(),
budget: BudgetSettings.partial().optional(),
});
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
const { apiKey: _apiKey, compaction, ...pi } = patch.pi ?? {};
return {
automation: { ...current.automation, ...patch.automation },
cache: { ...current.cache, ...patch.cache },
organize: { ...current.organize, ...patch.organize },
retention: { ...current.retention, ...patch.retention },
pi: { ...current.pi, ...pi, compaction: { ...current.pi.compaction, ...compaction } },
budget: { ...current.budget, ...patch.budget },
};
}
/** settings 表行 */
export const SettingEntry = z.object({
key: SettingsKey,
value: z.unknown(),
updatedAt: Timestamp,
});
export type SettingEntry = z.infer<typeof SettingEntry>;
+261
View File
@@ -0,0 +1,261 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 来源域:站点、站点账号、Feed、站点资源、观察(REQ §4)
// 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "generic_rss"]);
export type SiteAdapter = z.infer<typeof SiteAdapter>;
export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]);
export type SiteHealth = z.infer<typeof SiteHealth>;
export const CircuitState = z.enum(["closed", "open", "half_open"]);
export type CircuitState = z.infer<typeof CircuitState>;
/** 站点级限流(快照、实时搜索、浏览器共享) */
export const SiteRateLimit = z.object({
requestsPerMinute: z.number().int().positive().optional(),
minIntervalMs: z.number().int().nonnegative().optional(),
concurrency: z.number().int().positive().optional(),
});
export type SiteRateLimit = z.infer<typeof SiteRateLimit>;
export const Site = EntityBase.extend({
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
name: z.string().min(1),
adapter: SiteAdapter,
baseUrl: z.url(),
enabled: z.boolean(),
rateLimit: SiteRateLimit,
config: JsonObject,
healthStatus: SiteHealth,
circuitState: CircuitState,
circuitOpenUntil: Timestamp.nullable(),
snapshotEnabled: z.boolean(),
snapshotIntervalSec: z.number().int().positive().nullable(),
lastSnapshotAt: Timestamp.nullable(),
snapshotCursor: JsonObject,
});
export type Site = z.infer<typeof Site>;
export const SiteCreate = Site.pick({
key: true,
name: true,
adapter: true,
baseUrl: true,
enabled: true,
rateLimit: true,
config: true,
snapshotEnabled: true,
snapshotIntervalSec: true,
}).partial({ enabled: true, rateLimit: true, config: true, snapshotEnabled: true, snapshotIntervalSec: true });
export type SiteCreate = z.infer<typeof SiteCreate>;
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
export type SiteUpdate = z.infer<typeof SiteUpdate>;
export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]);
export type SiteAuthKind = z.infer<typeof SiteAuthKind>;
export const SiteAccountStatus = z.enum(["unknown", "active", "invalid", "expired"]);
export type SiteAccountStatus = z.infer<typeof SiteAccountStatus>;
export const SiteAccount = EntityBase.extend({
siteId: Id,
label: z.string().min(1),
authKind: SiteAuthKind,
secretRef: Id.nullable(),
enabled: z.boolean(),
status: SiteAccountStatus,
lastVerifiedAt: Timestamp.nullable(),
userInfo: JsonObject,
});
export type SiteAccount = z.infer<typeof SiteAccount>;
export const SiteAccountCreate = SiteAccount.pick({ siteId: true, label: true, authKind: true, enabled: true })
.partial({ enabled: true })
.extend({ credential: z.string().min(1).max(16384) });
export type SiteAccountCreate = z.infer<typeof SiteAccountCreate>;
export const SiteAccountUpdate = SiteAccountCreate.omit({ siteId: true }).partial();
export type SiteAccountUpdate = z.infer<typeof SiteAccountUpdate>;
export const FeedPurpose = z.enum(["movie", "series", "variety", "promotion", "search", "generic"]);
export type FeedPurpose = z.infer<typeof FeedPurpose>;
export const FeedKind = z.enum(["rss", "atom", "list", "query"]);
export type FeedKind = z.infer<typeof FeedKind>;
export const FeedHealth = z.enum(["unknown", "healthy", "degraded", "failing", "circuit_open", "disabled"]);
export type FeedHealth = z.infer<typeof FeedHealth>;
export const FeedSource = EntityBase.extend({
siteId: Id.nullable(),
siteAccountId: Id.nullable(),
name: z.string().min(1),
purpose: FeedPurpose,
kind: FeedKind,
urlSecretRef: Id.nullable(),
/** 脱敏 URL(passkey 等参数替换为 [REDACTED]) */
urlDisplay: z.string(),
parserVersion: z.string(),
enabled: z.boolean(),
pollIntervalSec: z.number().int().min(60),
filter: JsonObject,
cursor: JsonObject,
etag: z.string().nullable(),
lastModified: z.string().nullable(),
lastFetchAt: Timestamp.nullable(),
lastSuccessAt: Timestamp.nullable(),
nextFetchAt: Timestamp.nullable(),
consecutiveFailures: NonNegInt,
backoffUntil: Timestamp.nullable(),
healthStatus: FeedHealth,
lastError: JsonObject.nullable(),
});
export type FeedSource = z.infer<typeof FeedSource>;
/** url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay */
export const FeedSourceCreate = FeedSource.pick({
siteId: true,
siteAccountId: true,
name: true,
purpose: true,
kind: true,
enabled: true,
pollIntervalSec: true,
filter: true,
})
.partial({
siteId: true,
siteAccountId: true,
purpose: true,
kind: true,
enabled: true,
pollIntervalSec: true,
filter: true,
})
.extend({ url: z.url() });
export type FeedSourceCreate = z.infer<typeof FeedSourceCreate>;
export const FeedSourceUpdate = FeedSourceCreate.partial();
export type FeedSourceUpdate = z.infer<typeof FeedSourceUpdate>;
export const ResolutionStatus = z.enum(["unresolved", "resolved", "ambiguous", "unidentifiable", "rejected"]);
export type ResolutionStatus = z.infer<typeof ResolutionStatus>;
export const ReleaseAvailability = z.enum(["active", "site_disabled", "expired", "unknown"]);
export type ReleaseAvailability = z.infer<typeof ReleaseAvailability>;
/** 促销状态必须带观察时间与过期时间,下单前按策略复核(REQ §4.3) */
export const Promotion = z.object({
kind: z.string().optional(),
observedAt: Timestamp.optional(),
expiresAt: Timestamp.nullable().optional(),
raw: z.unknown().optional(),
});
export type Promotion = z.infer<typeof Promotion>;
export const TorrentFileEntry = z.object({
path: z.string(),
size: NonNegInt,
});
export type TorrentFileEntry = z.infer<typeof TorrentFileEntry>;
export const SourceRelease = EntityBase.extend({
siteId: Id,
siteReleaseId: z.string().min(1),
title: z.string(),
subtitle: z.string().nullable(),
categoryRaw: z.string().nullable(),
tagsRaw: z.array(z.string()),
sizeBytes: NonNegInt.nullable(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
fileList: z.array(TorrentFileEntry).nullable(),
promotion: Promotion,
promotionExpiresAt: Timestamp.nullable(),
seeders: z.number().int().nullable(),
leechers: z.number().int().nullable(),
/** 站点发布时间;区别于 firstSeenAt(本系统首次发现) */
sourcePublishedAt: Timestamp.nullable(),
firstSeenAt: Timestamp,
lastSeenAt: Timestamp,
detailFetchedAt: Timestamp.nullable(),
/** 标题解析结果(标题/年份/季集/分辨率/来源/编码/发布组…) */
parsed: JsonObject,
parserVersion: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
resolutionStatus: ResolutionStatus,
resolutionEvidence: JsonObject,
availability: ReleaseAvailability,
raw: JsonObject,
});
export type SourceRelease = z.infer<typeof SourceRelease>;
/** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */
export const SourceReleaseCreate = SourceRelease.omit({
...SERVER_MANAGED,
firstSeenAt: true,
lastSeenAt: true,
}).partial({
subtitle: true,
categoryRaw: true,
tagsRaw: true,
sizeBytes: true,
infohashV1: true,
infohashV2: true,
fileList: true,
promotion: true,
promotionExpiresAt: true,
seeders: true,
leechers: true,
sourcePublishedAt: true,
detailFetchedAt: true,
parsed: true,
parserVersion: true,
mediaEntityId: true,
contentUnitId: true,
resolutionStatus: true,
resolutionEvidence: true,
availability: true,
raw: true,
});
export type SourceReleaseCreate = z.infer<typeof SourceReleaseCreate>;
export const SourceReleaseUpdate = SourceReleaseCreate.omit({ siteId: true, siteReleaseId: true }).partial();
export type SourceReleaseUpdate = z.infer<typeof SourceReleaseUpdate>;
export const ObservationStatus = z.enum(["new", "linked", "ignored", "error"]);
export type ObservationStatus = z.infer<typeof ObservationStatus>;
export const Observation = EntityBase.extend({
feedId: Id,
guid: z.string().min(1),
contentDigest: z.string().min(1),
sourceReleaseId: Id.nullable(),
title: z.string(),
/** 已脱敏的链接;原始下载链接不对外 */
linkRedacted: z.string().nullable(),
raw: JsonObject,
sourcePublishedAt: Timestamp.nullable(),
fetchedAt: Timestamp,
firstSeenAt: Timestamp,
lastSeenAt: Timestamp,
status: ObservationStatus,
resolveAttempts: NonNegInt,
nextResolveAt: Timestamp.nullable(),
lastError: JsonObject.nullable(),
});
export type Observation = z.infer<typeof Observation>;
export const ObservationCreate = Observation.pick({
feedId: true,
guid: true,
contentDigest: true,
sourceReleaseId: true,
title: true,
linkRedacted: true,
raw: true,
sourcePublishedAt: true,
fetchedAt: true,
}).partial({ sourceReleaseId: true, linkRedacted: true, raw: true, sourcePublishedAt: true, fetchedAt: true });
export type ObservationCreate = z.infer<typeof ObservationCreate>;
export const ObservationUpdate = Observation.pick({
contentDigest: true,
sourceReleaseId: true,
title: true,
raw: true,
status: true,
resolveAttempts: true,
nextResolveAt: true,
lastError: true,
}).partial();
export type ObservationUpdate = z.infer<typeof ObservationUpdate>;