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; export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]); export type ExternalEntityType = z.infer; /** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */ export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]); export type EvidenceLevel = z.infer; export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]); export type IdentityVerifyStatus = z.infer; 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; 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; /** 变更一律生成 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; export const IdentityRevisionAction = z.enum([ "created", "status_changed", "relinked", "evidence_updated", "locked", "unlocked", ]); export type IdentityRevisionAction = z.infer; /** 身份修订历史:只追加,不修改 */ 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; /** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */ 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; // ── W2-B:待识别池、识别尝试、合并跳转与人工纠错(REQ §7.2、§7.5) ── export const IdentityCandidateStatus = z.enum(["pending", "resolved", "dismissed", "archived"]); export type IdentityCandidateStatus = z.infer; /** proposed = 证据足够但自动化关闭/只读,待确认 */ export const IdentityCandidateReason = z.enum(["ambiguous", "no_match", "proposed", "lookup_failed"]); export type IdentityCandidateReason = z.infer; /** 待识别池里的一个候选(只存 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; /** 待识别池条目:每条 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; export const ResolutionOutcome = z.enum([ "auto", "proposed", "ambiguous", "no_match", "lookup_failed", "manual", "skipped_locked", ]); export type ResolutionOutcome = z.infer; /** 一次识别尝试(只追加) */ 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; /** 作品合并后的旧 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; /** 外部身份引用(provider + 类型 + ID) */ export const ExternalRef = z.object({ provider: IdentityProvider, entityType: z.enum(["movie", "tv"]), externalId: z.string().min(1), }); export type ExternalRef = z.infer; /** * 人工指定来源资源的身份:本地作品 / 外部身份(未登记时按外部详情建档)/ 池内候选序号,三选一。 * 指定后加人工锁,自动识别不再改写。 */ 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; /** 改作品的外部映射:旧的同 provider+类型 accepted 映射退役(留修订),新映射 accepted + 人工锁 */ export const EntityIdentitySet = ExternalRef.extend({ reason: z.string().max(2000).optional(), /** 默认加人工锁 */ lock: z.boolean().default(true), }); export type EntityIdentitySet = z.infer; 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; /** 待识别池列表条目:附来源资源摘要 */ 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;