Files
UHD_Media_Tool/packages/contracts/src/identity.ts
T
rootandClaude Opus 5.5 d3ab54f2bd feat(server): W4-A integration endpoints and contract gaps
- POST /acquisition-plans: manual submit via download.plan -> download.submit
  (same authorization/audit/idempotency path as the agent), slotId or entityId
- read-only /media-assets, /asset-locations, /asset-unit-coverage, /path-bindings,
  /reconcile-runs, /server-mappings, /artifacts, GET /title-aliases/{id};
  unimplemented write endpoints removed from api.ts; registry test now checks both directions
- candidate evaluation upsert per (slot, source_release) + migration 0022 dedupe/unique index
- organization records expose downloadTaskId (+ list filter); inventory adds
  subscribed/candidateReasons; feed sources expose derived possibleGap
- GET /events?after=&limit= JSON replay page for API key callers (same sse_event table)
- preference tag revision history from audit_event; subscriptions/identities/aliases
  lists carry mediaEntityTitle; subscription q/mediaKind search; slot subscriptionId
  filter + currentAsset summary; policy preview samples carry title/reasons
- DecisionSummary, IdentityRevisionList, ReleaseIdentityResult, PolicyPreviewSummary exported from contracts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:10:02 +08:00

263 lines
9.5 KiB
TypeScript

import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject, Timestamp } from "./common.ts";
// 身份域:外部身份映射与修订(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,
/** 派生字段(列表返回):作品标题(挂在内容单元上时取所属作品) */
mediaEntityTitle: z.string().nullable().optional(),
});
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>;
// ── W2-B:待识别池、识别尝试、合并跳转与人工纠错(REQ §7.2、§7.5) ──
export const IdentityCandidateStatus = z.enum(["pending", "resolved", "dismissed", "archived"]);
export type IdentityCandidateStatus = z.infer<typeof IdentityCandidateStatus>;
/** proposed = 证据足够但自动化关闭/只读,待确认 */
export const IdentityCandidateReason = z.enum(["ambiguous", "no_match", "proposed", "lookup_failed"]);
export type IdentityCandidateReason = z.infer<typeof IdentityCandidateReason>;
/** 待识别池里的一个候选(只存 JSON,不建正式作品) */
export const PoolCandidate = z.object({
provider: z.string(),
entityType: z.enum(["movie", "tv"]),
externalId: z.string(),
title: z.string(),
year: z.number().int().nullable(),
/** 本地候选的作品 ID */
localEntityId: Id.nullable(),
level: z.enum(["rule_confirmed", "multi_evidence", "needs_confirm", "rejected"]),
evidence: z.array(z.string()),
});
export type PoolCandidate = z.infer<typeof PoolCandidate>;
/** 待识别池条目:每条 source_release 至多一条;失败原因、证据版本与下一次复查时间 */
export const IdentityCandidate = EntityBase.extend({
sourceReleaseId: Id,
status: IdentityCandidateStatus,
reason: IdentityCandidateReason,
/** 解析出的检索条件(标题/年份/季集) */
query: JsonObject,
candidates: z.array(PoolCandidate),
evidenceVersion: z.string(),
attempts: z.number().int().nonnegative(),
lastAttemptAt: Timestamp.nullable(),
nextReviewAt: Timestamp.nullable(),
decisionId: Id.nullable(),
resolvedMediaEntityId: Id.nullable(),
resolvedBy: z.string().nullable(),
resolvedAt: Timestamp.nullable(),
});
export type IdentityCandidate = z.infer<typeof IdentityCandidate>;
export const ResolutionOutcome = z.enum([
"auto",
"proposed",
"ambiguous",
"no_match",
"lookup_failed",
"manual",
"skipped_locked",
]);
export type ResolutionOutcome = z.infer<typeof ResolutionOutcome>;
/** 一次识别尝试(只追加) */
export const ResolutionAttempt = EntityBase.extend({
sourceReleaseId: Id,
observationId: Id.nullable(),
outcome: ResolutionOutcome,
reason: z.string().nullable(),
evidenceVersion: z.string(),
candidateCount: z.number().int().nonnegative(),
mediaEntityId: Id.nullable(),
best: JsonObject.nullable(),
error: JsonObject.nullable(),
actorType: ActorType,
actorId: z.string().nullable(),
});
export type ResolutionAttempt = z.infer<typeof ResolutionAttempt>;
/** 作品合并后的旧 ID 跳转 */
export const IdentityRedirect = EntityBase.extend({
fromEntityId: Id,
toEntityId: Id,
reason: z.string().nullable(),
actorType: ActorType,
actorId: z.string().nullable(),
});
export type IdentityRedirect = z.infer<typeof IdentityRedirect>;
/** 外部身份引用(provider + 类型 + ID) */
export const ExternalRef = z.object({
provider: IdentityProvider,
entityType: z.enum(["movie", "tv"]),
externalId: z.string().min(1),
});
export type ExternalRef = z.infer<typeof ExternalRef>;
/**
* 人工指定来源资源的身份:本地作品 / 外部身份(未登记时按外部详情建档)/ 池内候选序号,三选一。
* 指定后加人工锁,自动识别不再改写。
*/
export const ReleaseIdentityAssign = z
.object({
mediaEntityId: Id.optional(),
external: ExternalRef.optional(),
candidateIndex: z.number().int().min(0).optional(),
/** 同时关联到某个内容单元(季/集);缺省按解析出的季集建临时单元 */
contentUnitId: Id.nullable().optional(),
lock: z.boolean().default(true),
reason: z.string().max(2000).optional(),
})
.refine((v) => [v.mediaEntityId, v.external, v.candidateIndex].filter((x) => x !== undefined).length === 1, {
message: "mediaEntityId / external / candidateIndex 必须且只能给一个",
});
export type ReleaseIdentityAssign = z.infer<typeof ReleaseIdentityAssign>;
/** 改作品的外部映射:旧的同 provider+类型 accepted 映射退役(留修订),新映射 accepted + 人工锁 */
export const EntityIdentitySet = ExternalRef.extend({
reason: z.string().max(2000).optional(),
/** 默认加人工锁 */
lock: z.boolean().default(true),
});
export type EntityIdentitySet = z.infer<typeof EntityIdentitySet>;
export const IdentityPoolQuery = z.object({
status: IdentityCandidateStatus.optional(),
reason: IdentityCandidateReason.optional(),
/** 按原始标题/解析标题检索(已观察到的资源必须能从待识别入口找回) */
q: z.string().trim().min(1).max(200).optional(),
siteId: Id.optional(),
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type IdentityPoolQuery = z.infer<typeof IdentityPoolQuery>;
/** 待识别池列表条目:附来源资源摘要 */
export const IdentityPoolItem = IdentityCandidate.extend({
release: z.object({
id: Id,
siteId: Id,
siteReleaseId: z.string(),
title: z.string(),
subtitle: z.string().nullable(),
categoryRaw: z.string().nullable(),
firstSeenAt: Timestamp,
resolutionStatus: z.string(),
}),
});
export type IdentityPoolItem = z.infer<typeof IdentityPoolItem>;