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:
1 parent
7ba712ea55
commit
cc8b3a7df4
42 files changed
+7026
-25
No files matched your search
@@ -1,3 +1,18 @@
|
||||
# @umt/contracts
|
||||
|
||||
所有 API、SSE 事件与能力(capability)输入输出的唯一定义(zod 4 schema + 推导出的 TS 类型)。web 与 server 共用,任何一端都不得手写重复 DTO;新增接口先在这里加 schema,再由 server 用 `@hono/zod-openapi` 生成 OpenAPI。
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `common.ts` | Id、Timestamp、DateOnly、JsonObject、游标分页、错误码、响应信封、ApiScope、ActorType |
|
||||
| `content.ts` / `identity.ts` / `assets.ts` / `sources.ts` / `policy.ts` / `execution.ts` / `agent.ts` / `ops.ts` / `organization.ts` / `instances.ts` | 每张表的 Row schema(camelCase、日期为 ISO 字符串)+ `XxxCreate` / `XxxUpdate` 输入 |
|
||||
| `organization.ts` | 另含公开整理记录合同:`PublicOrganizationRecord`、`OrganizationChangesQuery/Response`(change_seq 游标)、`OrganizeRequest` |
|
||||
| `settings.ts` | `GlobalSettings`、`DEFAULT_SETTINGS`、`GlobalSettingsUpdate`、`mergeSettings()` |
|
||||
| `capability.ts` / `capabilities-io.ts` | `CapabilityDescriptor`、首批 14 个能力 ID 与目录、各能力 Input/Output schema、`CAPABILITY_IO` |
|
||||
| `events.ts` | SSE 信封、`EventType`、`EVENT_PAYLOADS`、`TypedEvent` 判别联合 |
|
||||
| `api.ts` | `Api` 端点表(method/path/scope/params/query/body/response)、`buildPath()`、`listEndpoints()` |
|
||||
|
||||
约定:
|
||||
- Row schema 与 `packages/db/src/types.ts` 的列一一对应(`test/db-sync.test.ts` 校验);只在 DB 存在的列(密文、key_hash、change_xid)不对外。
|
||||
- 凭证类输入(`credential`、`url`、`plaintext`、`apiKey`)只写不回显。
|
||||
- schema 不用 `.default()` 做可部分更新对象的缺省(zod 4 在 partial 里仍会填默认值);缺省值放常量。
|
||||
@@ -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>;
|
||||
@@ -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"]>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,198 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import * as C from "../src";
|
||||
|
||||
// 与 packages/db/src/types.ts(从迁移反射生成)做文本级对照:
|
||||
// 1) 每张表的列 ⇔ 对应 Row schema 的字段(camelCase),只允许显式列出的差异;
|
||||
// 2) 每个 CHECK 枚举 ⇔ contracts 中对应 z.enum 的取值。
|
||||
// 只读文件,不引入 @umt/db 依赖。
|
||||
|
||||
const typesSrc = readFileSync(new URL("../../db/src/types.ts", import.meta.url), "utf8");
|
||||
|
||||
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
||||
|
||||
function tableColumns(): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const m of typesSrc.matchAll(/export interface (\w+)Table \{\n([\s\S]*?)\n\}/g)) {
|
||||
const cols = [...m[2]!.matchAll(/^ {2}(\w+):/gm)].map((x) => x[1]!);
|
||||
out.set(m[1]!, cols);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function enumAliases(): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const m of typesSrc.matchAll(/export type (\w+) =([^;]+);/g)) {
|
||||
if (m[1] === "Timestamptz" || m[1] === "Json" || m[2]!.includes("<")) continue;
|
||||
out.set(
|
||||
m[1]!,
|
||||
[...m[2]!.matchAll(/"([^"]*)"/g)].map((x) => x[1]!),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Obj = z.ZodObject;
|
||||
|
||||
/** 表(PascalCase)→ Row schema 与允许的差异 */
|
||||
const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }> = {
|
||||
AcquisitionPlan: { row: C.AcquisitionPlan },
|
||||
AcquisitionSlot: { row: C.AcquisitionSlot },
|
||||
AgentRun: { row: C.AgentRun },
|
||||
ApiKey: { row: C.ApiKey, dbOnly: ["keyHash"] },
|
||||
Approval: { row: C.Approval },
|
||||
Artifact: { row: C.Artifact },
|
||||
AssetLocation: { row: C.AssetLocation },
|
||||
AssetOperation: { row: C.AssetOperation },
|
||||
AssetUnitCoverage: { row: C.AssetUnitCoverage },
|
||||
AuditEvent: { row: C.AuditEvent },
|
||||
AutomationGrant: { row: C.AutomationGrant },
|
||||
ContentUnit: { row: C.ContentUnit },
|
||||
Decision: { row: C.Decision },
|
||||
DownloadTask: { row: C.DownloadTask },
|
||||
DownloaderInstance: { row: C.DownloaderInstance },
|
||||
ExternalIdentity: { row: C.ExternalIdentity },
|
||||
FeedSource: { row: C.FeedSource },
|
||||
IdentityRevision: { row: C.IdentityRevision },
|
||||
LibraryRoot: { row: C.LibraryRoot },
|
||||
MediaAsset: { row: C.MediaAsset },
|
||||
MediaEntity: { row: C.MediaEntity },
|
||||
MediaServerInstance: { row: C.MediaServerInstance },
|
||||
Observation: { row: C.Observation },
|
||||
OrganizationFileResult: { row: C.OrganizationFileResult },
|
||||
OrganizationRecord: { row: C.OrganizationRecord, dbOnly: ["changeXid"] },
|
||||
OutboxEvent: { row: C.OutboxEvent },
|
||||
PathBinding: { row: C.PathBinding },
|
||||
PathMapping: { row: C.PathMapping },
|
||||
PolicyRevision: { row: C.PolicyRevision },
|
||||
PreferenceSuppression: { row: C.PreferenceSuppression },
|
||||
PreferenceTag: { row: C.PreferenceTag },
|
||||
ReconcileRun: { row: C.ReconcileRun },
|
||||
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
|
||||
ServerMapping: { row: C.ServerMapping },
|
||||
Settings: { row: C.SettingEntry },
|
||||
Site: { row: C.Site },
|
||||
SiteAccount: { row: C.SiteAccount },
|
||||
SourceRelease: { row: C.SourceRelease },
|
||||
Subscription: { row: C.Subscription },
|
||||
Task: { row: C.Task },
|
||||
TitleAlias: { row: C.TitleAlias },
|
||||
};
|
||||
|
||||
/** DB 枚举类型名 → contracts 枚举 */
|
||||
const ENUMS: Record<string, z.ZodEnum> = {
|
||||
AcquisitionPlanPurpose: C.PlanPurpose,
|
||||
AcquisitionPlanStatus: C.PlanStatus,
|
||||
AcquisitionPlanPriority: C.AcquisitionPriority,
|
||||
AcquisitionSlotStatus: C.SlotStatus,
|
||||
AgentRunTrigger: C.AgentTrigger,
|
||||
AgentRunStatus: C.AgentRunStatus,
|
||||
ApprovalStatus: C.ApprovalStatus,
|
||||
ArtifactKind: C.ArtifactKind,
|
||||
AssetLocationLocationKind: C.LocationKind,
|
||||
AssetLocationStatus: C.LocationStatus,
|
||||
AssetOperationPhase: C.OperationPhase,
|
||||
AssetOperationKind: C.OperationKind,
|
||||
AssetOperationStatus: C.OperationStatus,
|
||||
AssetUnitCoverageCoverage: C.CoverageKind,
|
||||
AssetUnitCoverageSegment: C.Segment,
|
||||
AutomationGrantStatus: C.GrantStatus,
|
||||
ContentUnitKind: C.ContentUnitKind,
|
||||
ContentUnitContentType: C.ContentType,
|
||||
ContentUnitSegment: C.Segment,
|
||||
ContentUnitStatus: C.ContentUnitStatus,
|
||||
DecisionKind: C.DecisionKind,
|
||||
DecisionStatus: C.DecisionStatus,
|
||||
DecisionSeverity: C.Severity,
|
||||
DownloadTaskOrigin: C.DownloadOrigin,
|
||||
DownloadTaskStatus: C.DownloadStatus,
|
||||
DownloadTaskPauseReason: C.PauseReason,
|
||||
DownloaderInstanceKind: C.DownloaderKind,
|
||||
DownloaderInstanceHealthStatus: C.InstanceHealth,
|
||||
ExternalIdentityEntityType: C.ExternalEntityType,
|
||||
ExternalIdentityStatus: C.IdentityStatus,
|
||||
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
|
||||
ExternalIdentityVerifyStatus: C.IdentityVerifyStatus,
|
||||
FeedSourcePurpose: C.FeedPurpose,
|
||||
FeedSourceKind: C.FeedKind,
|
||||
FeedSourceHealthStatus: C.FeedHealth,
|
||||
IdentityRevisionAction: C.IdentityRevisionAction,
|
||||
IdentityRevisionFromStatus: C.IdentityStatus,
|
||||
IdentityRevisionToStatus: C.IdentityStatus,
|
||||
IdentityRevisionActorType: C.ActorType,
|
||||
LibraryRootMediaKind: C.LibraryMediaKind,
|
||||
LibraryRootManagementMode: C.ManagementMode,
|
||||
LibraryRootHealthStatus: C.InstanceHealth,
|
||||
MediaAssetKind: C.AssetKind,
|
||||
MediaAssetStatus: C.AssetStatus,
|
||||
MediaEntityKind: C.MediaKind,
|
||||
MediaEntityStatus: C.MediaEntityStatus,
|
||||
MediaServerInstanceKind: C.MediaServerKind,
|
||||
MediaServerInstanceHealthStatus: C.InstanceHealth,
|
||||
ObservationStatus: C.ObservationStatus,
|
||||
OrganizationFileResultRole: C.FileRole,
|
||||
OrganizationFileResultMode: C.OrganizationAction,
|
||||
OrganizationFileResultStatus: C.FileResultStatus,
|
||||
OrganizationRecordTrigger: C.OrganizationTrigger,
|
||||
OrganizationRecordActorType: C.ActorType,
|
||||
OrganizationRecordSourcePathView: C.SourcePathView,
|
||||
OrganizationRecordAction: C.OrganizationAction,
|
||||
OrganizationRecordStatus: C.OrganizationStatus,
|
||||
OutboxEventStatus: C.OutboxStatus,
|
||||
PathBindingBindingKind: C.BindingKind,
|
||||
PathBindingStatus: C.BindingStatus,
|
||||
PathMappingViewKind: C.PathViewKind,
|
||||
PathMappingVerifyStatus: C.PathMappingVerifyStatus,
|
||||
PolicyRevisionKind: C.PolicyKind,
|
||||
PolicyRevisionStatus: C.PolicyStatus,
|
||||
PreferenceTagKind: C.PreferenceKind,
|
||||
PreferenceTagOrigin: C.PreferenceOrigin,
|
||||
PreferenceTagStatus: C.PreferenceStatus,
|
||||
ReconcileRunKind: C.ReconcileKind,
|
||||
ReconcileRunMode: C.ReconcileMode,
|
||||
ReconcileRunStatus: C.ReconcileStatus,
|
||||
SecretPurpose: C.SecretPurpose,
|
||||
ServerMappingItemType: C.ServerItemType,
|
||||
ServerMappingMatchStatus: C.ServerMatchStatus,
|
||||
SiteAdapter: C.SiteAdapter,
|
||||
SiteHealthStatus: C.SiteHealth,
|
||||
SiteCircuitState: C.CircuitState,
|
||||
SiteAccountAuthKind: C.SiteAuthKind,
|
||||
SiteAccountStatus: C.SiteAccountStatus,
|
||||
SourceReleaseResolutionStatus: C.ResolutionStatus,
|
||||
SourceReleaseAvailability: C.ReleaseAvailability,
|
||||
SubscriptionMediaKind: C.SubscriptionMediaKind,
|
||||
SubscriptionMode: C.SubscriptionMode,
|
||||
SubscriptionSeasonScope: C.SeasonScope,
|
||||
SubscriptionOrigin: C.SubscriptionOrigin,
|
||||
SubscriptionPriority: C.AcquisitionPriority,
|
||||
SubscriptionStatus: C.SubscriptionStatus,
|
||||
TaskStatus: C.TaskStatus,
|
||||
TitleAliasKind: C.AliasKind,
|
||||
TitleAliasSource: C.AliasSource,
|
||||
};
|
||||
|
||||
describe("contracts ⇔ db 表结构同步", () => {
|
||||
const tables = tableColumns();
|
||||
|
||||
it("每张表都有 Row schema", () => {
|
||||
expect([...tables.keys()].sort()).toEqual(Object.keys(ROWS).sort());
|
||||
});
|
||||
|
||||
for (const [table, spec] of Object.entries(ROWS)) {
|
||||
it(`${table} 列与 Row 字段一致`, () => {
|
||||
const dbCols = (tables.get(table) ?? []).map(camel).filter((c) => !spec.dbOnly?.includes(c));
|
||||
const apiCols = Object.keys(spec.row.shape).filter((c) => !spec.apiOnly?.includes(c));
|
||||
expect(apiCols.sort()).toEqual(dbCols.sort());
|
||||
});
|
||||
}
|
||||
|
||||
it("每个 DB 枚举都有对应 contracts 枚举且取值一致", () => {
|
||||
const aliases = enumAliases();
|
||||
expect([...aliases.keys()].sort()).toEqual(Object.keys(ENUMS).sort());
|
||||
for (const [name, values] of aliases) {
|
||||
expect(ENUMS[name]!.options, name).toEqual(values);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as C from "../src";
|
||||
|
||||
// 对 contracts 导出的每个 zod schema 做正反例 parse:
|
||||
// 正例由 sample() 按 schema 结构生成(字符串/数字按候选值逐个试到通过),个别 refine 由 OVERRIDES 修正;
|
||||
// 反例把一个必填字段替换成非法值(对象)或直接传 Symbol(非对象)。
|
||||
|
||||
interface Def {
|
||||
type: string;
|
||||
innerType?: z.ZodType;
|
||||
shape?: Record<string, z.ZodType>;
|
||||
options?: z.ZodType[];
|
||||
entries?: Record<string, string>;
|
||||
values?: unknown[];
|
||||
element?: z.ZodType;
|
||||
in?: z.ZodType;
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
const def = (s: z.ZodType) => (s as unknown as { _zod: { def: Def } })._zod.def;
|
||||
|
||||
const UUID = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
|
||||
const STRING_CANDIDATES = [
|
||||
UUID,
|
||||
"2026-09-27T08:00:00.000Z",
|
||||
"2026-09-27",
|
||||
"https://example.test/x",
|
||||
"a".repeat(40),
|
||||
"/media/movies",
|
||||
"tmdb",
|
||||
"123",
|
||||
"CatalogLookupInput",
|
||||
"catalog.lookup",
|
||||
"测试 Test",
|
||||
];
|
||||
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
|
||||
|
||||
function pick<T>(schema: z.ZodType, candidates: readonly T[]): T {
|
||||
const hit = candidates.find((c) => schema.safeParse(c).success);
|
||||
if (hit === undefined) throw new Error(`没有候选值能通过 ${def(schema).type}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
function sample(schema: z.ZodType): unknown {
|
||||
const d = def(schema);
|
||||
switch (d.type) {
|
||||
case "string":
|
||||
return pick(schema, STRING_CANDIDATES);
|
||||
case "number":
|
||||
return pick(schema, NUMBER_CANDIDATES);
|
||||
case "boolean":
|
||||
return true;
|
||||
case "enum":
|
||||
return Object.values(d.entries!)[0];
|
||||
case "literal":
|
||||
return d.values![0];
|
||||
case "nullable":
|
||||
case "optional":
|
||||
case "default":
|
||||
case "prefault":
|
||||
case "readonly":
|
||||
return sample(d.innerType!);
|
||||
case "array":
|
||||
return [sample(d.element!)];
|
||||
case "record":
|
||||
return { k: "v" };
|
||||
case "unknown":
|
||||
return { any: 1 };
|
||||
case "union":
|
||||
return sample(d.options![0]!);
|
||||
case "pipe":
|
||||
return sample(d.in!);
|
||||
case "object": {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(d.shape!)) out[k] = sample(v);
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
throw new Error(`sample 未支持 ${d.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 需要满足跨字段 refine 的正例修正 */
|
||||
const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, unknown>> = {
|
||||
PathMappingCreate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
|
||||
PathMappingUpdate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
|
||||
ExternalIdentityCreate: (v) => ({ ...v, contentUnitId: null }),
|
||||
PathBindingCreate: (v) => ({ ...v, bindingKind: "season", seasonNumber: 1 }),
|
||||
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
|
||||
CatalogLookupInput: (v) => v,
|
||||
IdentityResolveInput: (v) => v,
|
||||
AssetLocationCreate: (v) => v,
|
||||
CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }),
|
||||
GlobalSettings: () => C.DEFAULT_SETTINGS,
|
||||
TypedEventEnvelope: () => ({
|
||||
seq: 1,
|
||||
at: "2026-09-27T08:00:00Z",
|
||||
type: "system.heartbeat",
|
||||
payload: { role: "all" },
|
||||
}),
|
||||
};
|
||||
|
||||
const isSchema = (v: unknown): v is z.ZodType => v instanceof z.ZodType;
|
||||
const schemas: [string, z.ZodType][] = [];
|
||||
for (const [k, v] of Object.entries(C) as [string, unknown][]) if (isSchema(v)) schemas.push([k, v]);
|
||||
|
||||
describe("所有导出 schema 的正反例", () => {
|
||||
it("导出了足够多的 schema", () => {
|
||||
expect(schemas.length).toBeGreaterThan(150);
|
||||
});
|
||||
|
||||
for (const [name, schema] of schemas) {
|
||||
it(name, () => {
|
||||
let good = sample(schema);
|
||||
const fix = OVERRIDES[name];
|
||||
if (fix && good && typeof good === "object") good = fix(good as Record<string, unknown>);
|
||||
const ok = schema.safeParse(good);
|
||||
if (!ok.success) throw new Error(`${name} 正例失败: ${ok.error.message}\n${JSON.stringify(good)}`);
|
||||
|
||||
// 反例
|
||||
const d = def(schema);
|
||||
if (d.type === "object") {
|
||||
const shape = d.shape!;
|
||||
const key = Object.keys(shape).find(
|
||||
(k) => !shape[k]!.safeParse(undefined).success && def(shape[k]!).type !== "unknown",
|
||||
);
|
||||
const bad = key ? { ...(good as object), [key]: Symbol("bad") } : Symbol("bad");
|
||||
expect(schema.safeParse(bad).success, `${name} 反例应失败(${key ?? "整体"})`).toBe(false);
|
||||
} else {
|
||||
expect(schema.safeParse(Symbol("bad")).success, `${name} 反例应失败`).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("关键合同行为", () => {
|
||||
it("Slot 状态集合与 REQ §6.1 一致", () => {
|
||||
expect(C.SlotStatus.options).toEqual([
|
||||
"empty",
|
||||
"reserved",
|
||||
"downloading",
|
||||
"verifying",
|
||||
"satisfied",
|
||||
"upgrade_pending",
|
||||
"attention",
|
||||
]);
|
||||
});
|
||||
|
||||
it("CacheSettings 拒绝恢复水位 ≥ 硬阈值", () => {
|
||||
expect(C.CacheSettings.safeParse({ ...C.DEFAULT_SETTINGS.cache, resumePercent: 95 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("GlobalSettingsUpdate 部分更新不会注入默认值,mergeSettings 保留现值", () => {
|
||||
const patch = C.GlobalSettingsUpdate.parse({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
|
||||
expect(patch).toEqual({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
|
||||
const merged = C.mergeSettings(C.DEFAULT_SETTINGS, patch);
|
||||
expect(merged.automation).toEqual({ enabled: false, readOnlyReconcile: false });
|
||||
expect(merged.pi).toEqual(C.DEFAULT_SETTINGS.pi);
|
||||
expect(C.GlobalSettings.safeParse(merged).success).toBe(true);
|
||||
});
|
||||
|
||||
it("OrganizationChangesQuery 接受查询串并给默认值", () => {
|
||||
expect(C.OrganizationChangesQuery.parse({ after: "42", limit: "10", includeFiles: "false" })).toEqual({
|
||||
after: "42",
|
||||
limit: 10,
|
||||
includeFiles: false,
|
||||
});
|
||||
expect(C.OrganizationChangesQuery.safeParse({ after: "-1" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("ExternalIdentityCreate 要求且只能有一个本地目标", () => {
|
||||
const base = { provider: "tmdb", entityType: "movie", externalId: "1" };
|
||||
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID }).success).toBe(true);
|
||||
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID, contentUnitId: UUID }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("能力目录覆盖首批 14 个能力且 schema 引用都存在", () => {
|
||||
expect(Object.keys(C.CAPABILITY_CATALOG).sort()).toEqual([...C.CAPABILITY_IDS].sort());
|
||||
const exported = C as Record<string, unknown>;
|
||||
for (const cap of Object.values(C.CAPABILITY_CATALOG)) {
|
||||
expect(C.CapabilityDescriptor.safeParse(cap).success).toBe(true);
|
||||
expect(isSchema(exported[cap.inputSchema]), cap.inputSchema).toBe(true);
|
||||
expect(isSchema(exported[cap.outputSchema]), cap.outputSchema).toBe(true);
|
||||
expect(exported[cap.inputSchema]).toBe(C.CAPABILITY_IO[cap.id as C.KnownCapabilityId].input);
|
||||
}
|
||||
});
|
||||
|
||||
it("事件 payload 表覆盖全部事件类型", () => {
|
||||
expect(Object.keys(C.EVENT_PAYLOADS).sort()).toEqual(Object.values(C.EventType).sort());
|
||||
expect(
|
||||
C.parseEventPayload("task.status_changed", {
|
||||
taskId: UUID,
|
||||
kind: "ingestFeed",
|
||||
from: null,
|
||||
to: "running",
|
||||
error: null,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(C.parseEventPayload("task.status_changed", { taskId: "x" })).toBeNull();
|
||||
expect(C.isEventType("nope.x")).toBe(false);
|
||||
});
|
||||
|
||||
it("API 端点路径唯一且 buildPath 填参", () => {
|
||||
const eps = C.listEndpoints();
|
||||
const keys = eps.map((e) => `${e.method} ${e.path}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(C.buildPath(C.Api.tasks.cancel.path, { id: UUID })).toBe(`/api/v1/tasks/${UUID}/cancel`);
|
||||
expect(() => C.buildPath(C.Api.tasks.get.path)).toThrow();
|
||||
// 公开整理记录读写分权
|
||||
expect(C.Api.organization.changes.scope).toBe("organize.read");
|
||||
expect(C.Api.organization.create.scope).toBe("organize.execute");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user