diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 99aed1f7..be074f74 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -569,20 +569,56 @@ export const Api = { ), // ── 规则 ── - subscriptions: crud( - "/subscriptions", - "订阅", - { - row: policy.Subscription, - create: policy.SubscriptionCreate, - update: policy.SubscriptionUpdate, - filters: { - status: policy.SubscriptionStatus.optional(), - mediaEntityId: Id.optional(), - mediaKind: policy.SubscriptionMediaKind.optional(), - /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ - q: z.string().max(200).optional(), + subscriptions: { + ...crud( + "/subscriptions", + "订阅", + { + row: policy.Subscription, + create: policy.SubscriptionCreate, + update: policy.SubscriptionUpdate, + filters: { + status: policy.SubscriptionStatus.optional(), + mediaEntityId: Id.optional(), + mediaKind: policy.SubscriptionMediaKind.optional(), + /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ + q: z.string().max(200).optional(), + }, }, + R, + ), + search: ep({ + method: "post", + path: "/subscriptions/{id}/search", + scope: "admin", + summary: "手动主动搜索(按标题/别名在已开启站点搜索,结果走同一识别评估链)", + params: IdParam, + response: z.object({ runId: z.string(), deduplicated: z.boolean() }), + }), + progress: ep({ + method: "get", + path: "/subscriptions/{id}/progress", + scope: "read", + summary: "订阅进度:季总集数账本、每集状态(已覆盖/在途/缺失/疑似缺集)、Slot 在途与延后", + params: IdParam, + response: policy.SubscriptionProgress, + }), + setEpisodeTotal: ep({ + method: "post", + path: "/subscriptions/{id}/episode-total", + scope: "admin", + summary: "手动设置季总集数(只升;locked 锁定后刷新不再改写)", + params: IdParam, + body: policy.EpisodeTotalSet, + response: policy.SubscriptionProgress, + }), + }, + subscriptionHistory: crud( + "/subscription-history", + "订阅完成历史", + { + row: policy.SubscriptionHistory, + filters: { mediaEntityId: Id.optional(), subscriptionId: Id.optional() }, }, R, ), diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts index 6691502c..4c2e94e5 100644 --- a/packages/contracts/src/content.ts +++ b/packages/contracts/src/content.ts @@ -59,6 +59,10 @@ export type Segment = z.infer; export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]); export type ContentUnitStatus = z.infer; +/** 季总集数来源:tmdb 元数据 / 插件注入(只升)/ 人工锁定 / 回退被已覆盖最高集兜住 / 沿用 */ +export const EpisodeTotalSource = z.enum(["tmdb", "injected", "manual", "covered_floor", "previous"]); +export type EpisodeTotalSource = z.infer; + export const ContentUnit = EntityBase.extend({ mediaEntityId: Id, parentUnitId: Id.nullable(), @@ -77,6 +81,12 @@ export const ContentUnit = EntityBase.extend({ numberingLocked: z.boolean(), metadata: JsonObject, fieldLocks: z.array(z.string()), + /** 季单元的总集数账本(只升;回退不低于已覆盖最高集);null = 未知,追更不结束 */ + episodeTotal: z.number().int().nonnegative().nullable(), + episodeTotalSource: EpisodeTotalSource.nullable(), + /** 人工锁定总集数:元数据刷新与注入不再改写 */ + episodeTotalLocked: z.boolean(), + episodeTotalAt: Timestamp.nullable(), }); export type ContentUnit = z.infer; export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({ @@ -93,6 +103,10 @@ export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({ numberingLocked: true, metadata: true, fieldLocks: true, + episodeTotal: true, + episodeTotalSource: true, + episodeTotalLocked: true, + episodeTotalAt: true, }); export type ContentUnitCreate = z.infer; export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial(); diff --git a/packages/contracts/src/execution.ts b/packages/contracts/src/execution.ts index 792c755f..8bf5d8df 100644 --- a/packages/contracts/src/execution.ts +++ b/packages/contracts/src/execution.ts @@ -92,7 +92,10 @@ export type PlanPurpose = z.infer; export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]); export type PlanStatus = z.infer; -/** 下载计划:与 Slot 预留、预算预留同事务创建;一个 Slot 同时只能有一个 reserved/submitted 计划 */ +/** + * 下载计划:与 Slot 预留、预算预留同事务创建。 + * 剧集 Slot 可同时有多个单元不相交的 reserved/submitted 计划(Slot×单元预留);电影 Slot 同时只有一个。 + */ export const AcquisitionPlan = EntityBase.extend({ slotId: Id, sourceReleaseId: Id, @@ -109,6 +112,10 @@ export const AcquisitionPlan = EntityBase.extend({ idempotencyKey: z.string().min(1), expiresAt: Timestamp.nullable(), statusReason: z.string().nullable(), + /** 计划预留的内容单元(content_unit id 与合成键 s{季}e{集};s{季}* = 整季未核对),在途期间计入缺集的"已预留" */ + unitKeys: z.array(z.string()), + /** 季包按缺集选文件的结果(提交后读文件清单;无法判定时整包下载并记理由) */ + fileSelection: JsonObject.nullable(), }); export type AcquisitionPlan = z.infer; export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({ @@ -121,6 +128,8 @@ export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, s expectedRevision: true, expiresAt: true, statusReason: true, + unitKeys: true, + fileSelection: true, }); export type AcquisitionPlanCreate = z.infer; diff --git a/packages/contracts/src/policy.ts b/packages/contracts/src/policy.ts index 2a585e02..8fb747a9 100644 --- a/packages/contracts/src/policy.ts +++ b/packages/contracts/src/policy.ts @@ -53,7 +53,11 @@ export type SubscriptionOrigin = z.infer; /** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */ export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]); export type AcquisitionPriority = z.infer; -export const SubscriptionStatus = z.enum(["active", "paused", "completed", "cancelled"]); +/** + * active 追踪中;paused 暂停(不评估、不搜索);pending TMDB 未就绪(可搜索/评估,不可完成,元数据就绪后转 active); + * completed 已完成(写 subscription_history,行保留可重新激活);cancelled 取消。 + */ +export const SubscriptionStatus = z.enum(["active", "paused", "pending", "completed", "cancelled"]); export type SubscriptionStatus = z.infer; export const Subscription = EntityBase.extend({ @@ -72,10 +76,35 @@ export const Subscription = EntityBase.extend({ statusReason: z.string().nullable(), lastCheckedAt: Timestamp.nullable(), createdBy: z.string().nullable(), + /** 上次主动搜索时间 / 下次到期(按 params.searchIntervalHours,缺省 24h) */ + lastSearchedAt: Timestamp.nullable(), + nextSearchAt: Timestamp.nullable(), /** 派生字段(列表返回):作品标题,未建档时为 null */ mediaEntityTitle: z.string().nullable().optional(), }); export type Subscription = z.infer; +/** + * subscription.params 的已知键(JSON 列,其余键原样保留): + * siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关); + * stopAfter 截止时间;downloaderInstanceId 指定下载器。 + */ +export const SubscriptionParams = z + .object({ + siteIds: z.array(Id).optional(), + episodeRange: z + .object({ start: z.number().int().min(1).optional(), end: z.number().int().min(1).optional() }) + .optional(), + searchIntervalHours: z + .number() + .min(0) + .max(24 * 30) + .optional(), + stopAfter: z.string().optional(), + downloaderInstanceId: Id.optional(), + }) + .passthrough(); +export type SubscriptionParams = z.infer; + export const SubscriptionCreate = Subscription.pick({ mediaEntityId: true, seasonUnitId: true, @@ -112,6 +141,96 @@ export const SubscriptionUpdate = Subscription.pick({ }).partial(); export type SubscriptionUpdate = z.infer; +/** 订阅完成历史:完成时写整行快照与进度;订阅行保留,可重新激活 */ +export const SubscriptionHistory = EntityBase.extend({ + subscriptionId: Id.nullable(), + mediaEntityId: Id.nullable(), + seasonUnitId: Id.nullable(), + mediaKind: z.string(), + mode: z.string(), + reason: z.string(), + snapshot: JsonObject, + progress: JsonObject, + completedAt: Timestamp, +}); +export type SubscriptionHistory = z.infer; + +/** Slot 忙时到达、待 Slot 状态变化后重评的发布 */ +export const SlotDeferral = EntityBase.extend({ + slotId: Id, + sourceReleaseId: Id, + reason: z.string(), + unitKeys: z.array(z.string()), + attempts: z.number().int().min(1), +}); +export type SlotDeferral = z.infer; + +/** 分集质量账本:Slot × 内容单元的当前资产与质量摘要(分集升级只替换该单元) */ +export const SlotUnit = EntityBase.extend({ + slotId: Id, + contentUnitId: Id, + mediaAssetId: Id.nullable(), + planId: Id.nullable(), + quality: JsonObject, + pri: z.number().int().nullable(), +}); +export type SlotUnit = z.infer; + +/** 单元状态(订阅详情"每单元状态") */ +export const UnitProgressState = z.enum(["covered", "inflight", "missing", "suspected_missing", "out_of_range"]); +export type UnitProgressState = z.infer; + +/** 订阅进度:按季的总集数账本 + 每集状态 + 每 Slot 在途/延后 */ +export const SubscriptionProgress = z.object({ + subscriptionId: Id, + status: z.string(), + /** 能否完成:总集数都已知且订阅不是 pending */ + completable: z.boolean(), + seasons: z.array( + z.object({ + seasonUnitId: Id.nullable(), + seasonNumber: z.number().int(), + episodeTotal: z.number().int().nullable(), + totalKnown: z.boolean(), + totalSource: z.string().nullable(), + totalLocked: z.boolean(), + units: z.array( + z.object({ + episode: z.number().int(), + state: UnitProgressState, + contentUnitId: Id.nullable(), + /** 该集当前版本(分集质量账本) */ + quality: z + .object({ resolution: z.string().nullable(), source: z.string().nullable(), pri: z.number().nullable() }) + .nullable(), + }), + ), + missing: z.array(z.number().int()), + suspectedMissing: z.array(z.number().int()), + complete: z.boolean(), + }), + ), + slots: z.array( + z.object({ + slotId: Id, + name: z.string(), + status: z.string(), + inflightPlans: z.array(z.object({ planId: Id, status: z.string(), unitKeys: z.array(z.string()) })), + deferred: z.number().int(), + nextEvaluateAt: Timestamp.nullable(), + }), + ), +}); +export type SubscriptionProgress = z.infer; + +/** 手动设置季总集数(只升;locked=true 锁定后元数据刷新不再改写,可用于调低) */ +export const EpisodeTotalSet = z.object({ + seasonNumber: z.number().int().min(0), + total: z.number().int().min(1).max(5000), + locked: z.boolean().optional(), +}); +export type EpisodeTotalSet = z.infer; + /** Slot 状态(REQ §6.1) */ export const SlotStatus = z.enum([ "empty", @@ -351,11 +470,17 @@ export const CandidateEvaluation = z.object({ sourceReleaseId: Id, title: z.string(), /** accept / upgrade / reject / unknown_field / skipped(被模式、预算、阈值、总开关等挡住) */ - decision: z.enum(["accept", "upgrade", "reject", "unknown_field", "skipped"]), + decision: z.enum(["accept", "upgrade", "reject", "unknown_field", "skipped", "deferred"]), mode: z.enum(["new_title", "catch_up", "upgrade"]).nullable(), rejected: z.array(z.object({ code: z.string(), message: z.string() })), reasons: z.array(z.string()), planId: Id.nullable(), evaluatedAt: Timestamp, + /** 优先级阶梯 pri(0–100;未配置阶梯时为 100) */ + pri: z.number().int().nullable().optional(), + /** 可读评估说明(逐条违规 + 逐层阶梯),对齐 MP trace */ + trace: z.array(z.string()).optional(), + /** 本次计划/延后涉及的内容单元 */ + unitKeys: z.array(z.string()).optional(), }); export type CandidateEvaluation = z.infer; diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index a7635ae3..30fe27b5 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -95,6 +95,10 @@ const ROWS: Record BrowserControlLease: { row: C.BrowserControlLease }, // W5-A 文件管理回收站(0024) TrashItem: { row: C.TrashItemRow }, + // W5-C 订阅增强(0025) + SlotDeferral: { row: C.SlotDeferral }, + SlotUnit: { row: C.SlotUnit }, + SubscriptionHistory: { row: C.SubscriptionHistory }, }; /** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */ @@ -223,6 +227,8 @@ const ENUMS: Record = { BrowserTabStatus: C.BrowserTabStatus, BrowserControlLeaseHolderKind: C.BrowserLeaseHolderKind, BrowserControlLeaseStatus: C.BrowserLeaseStatus, + // W5-C(0025) + ContentUnitEpisodeTotalSource: C.EpisodeTotalSource, }; describe("contracts ⇔ db 表结构同步", () => { diff --git a/packages/db/migrations/0025_subscription_units.ts b/packages/db/migrations/0025_subscription_units.ts new file mode 100644 index 00000000..2f9379ed --- /dev/null +++ b/packages/db/migrations/0025_subscription_units.ts @@ -0,0 +1,105 @@ +import type { Kysely } from "kysely"; +import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts"; + +// W5-C 订阅增强(docs/plan/05-subscription-gap.md §3): +// - subscription:pending 状态(TMDB 未就绪:可搜索/评估,不可完成);主动搜索的上次/下次时间; +// - content_unit(季):总集数账本 episode_total(只升;回退不低于已覆盖最高集;可人工锁定); +// - acquisition_plan:unit_keys 记录计划预留的内容单元(Slot×单元预留,等价 MP note);file_selection 记录季包选文件; +// 去掉"同 Slot 只允许一个在途计划"的唯一索引,改为服务端在 Slot 行锁内按单元不相交校验; +// - slot_deferral:Slot 忙(单元在途冲突 / 电影 Slot 在途)时到达的发布,计划结束后重评; +// - slot_unit:分集质量账本(Slot × 内容单元 → 当前资产 + 质量摘要 + 阶梯 pri),升级按单元替换; +// - subscription_history:订阅完成快照(完成后保留订阅行,可重新激活)。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +alter table subscription drop constraint subscription_status_check; +alter table subscription add constraint subscription_status_check ${oneOf("status", ["active", "paused", "pending", "completed", "cancelled"])}; +alter table subscription add column last_searched_at timestamptz; +alter table subscription add column next_search_at timestamptz; +create index subscription_next_search_idx on subscription (next_search_at) where next_search_at is not null; + +alter table content_unit add column episode_total integer check (episode_total is null or episode_total >= 0); +alter table content_unit add column episode_total_source text ${oneOf("episode_total_source", ["tmdb", "injected", "manual", "covered_floor", "previous"])}; +alter table content_unit add column episode_total_locked boolean not null default false; +alter table content_unit add column episode_total_at timestamptz; + +alter table acquisition_plan add column unit_keys text[] not null default '{}'::text[]; +alter table acquisition_plan add column file_selection jsonb; +drop index if exists acquisition_plan_slot_active_key; +create index acquisition_plan_slot_active_idx on acquisition_plan (slot_id) where status in ('reserved', 'submitted'); + +${entityTable( + "slot_deferral", + ` + slot_id uuid not null references acquisition_slot (id) on delete cascade, + source_release_id uuid not null references source_release (id) on delete cascade, + reason text not null, + unit_keys text[] not null default '{}'::text[], + attempts integer not null default 1, + constraint slot_deferral_key unique (slot_id, source_release_id)`, +)} + +${entityTable( + "slot_unit", + ` + slot_id uuid not null references acquisition_slot (id) on delete cascade, + content_unit_id uuid not null references content_unit (id) on delete cascade, + media_asset_id uuid references media_asset (id) on delete set null, + plan_id uuid references acquisition_plan (id) on delete set null, + quality jsonb not null default '{}'::jsonb, + pri integer, + constraint slot_unit_key unique (slot_id, content_unit_id)`, +)} +create index slot_unit_asset_idx on slot_unit (media_asset_id) where media_asset_id is not null; + +${entityTable( + "subscription_history", + ` + subscription_id uuid references subscription (id) on delete set null, + media_entity_id uuid references media_entity (id) on delete set null, + season_unit_id uuid references content_unit (id) on delete set null, + media_kind text not null, + mode text not null, + reason text not null, + snapshot jsonb not null default '{}'::jsonb, + progress jsonb not null default '{}'::jsonb, + completed_at timestamptz not null default now()`, +)} +create index subscription_history_entity_idx on subscription_history (media_entity_id, completed_at desc); +create index subscription_history_subscription_idx on subscription_history (subscription_id); +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + ` +${dropTables(["subscription_history", "slot_unit", "slot_deferral"])} +drop index if exists acquisition_plan_slot_active_idx; +-- 回到"每 Slot 一个在途":同 Slot 多个在途时只保留最早的一个,其余取消 +update acquisition_plan p set status = 'cancelled', status_reason = 'migration_down:multi_inflight' + where p.status in ('reserved', 'submitted') + and exists (select 1 from acquisition_plan q + where q.slot_id = p.slot_id and q.status in ('reserved', 'submitted') + and (q.created_at, q.id) < (p.created_at, p.id)); +create unique index acquisition_plan_slot_active_key on acquisition_plan (slot_id) where status in ('reserved', 'submitted'); +alter table acquisition_plan drop column if exists file_selection; +alter table acquisition_plan drop column if exists unit_keys; +alter table content_unit drop column if exists episode_total_at; +alter table content_unit drop column if exists episode_total_locked; +alter table content_unit drop column if exists episode_total_source; +alter table content_unit drop column if exists episode_total; +drop index if exists subscription_next_search_idx; +alter table subscription drop column if exists next_search_at; +alter table subscription drop column if exists last_searched_at; +update subscription set status = 'active' where status = 'pending'; +alter table subscription drop constraint subscription_status_check; +alter table subscription add constraint subscription_status_check ${oneOf("status", ["active", "paused", "completed", "cancelled"])}; +`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 78325240..7edecf62 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -91,6 +91,7 @@ export type ContentUnitContentType = | "unknown"; export type ContentUnitSegment = "full" | "upper" | "middle" | "lower" | "unknown"; export type ContentUnitStatus = "provisional" | "confirmed" | "retired"; +export type ContentUnitEpisodeTotalSource = "tmdb" | "injected" | "manual" | "covered_floor" | "previous"; export type DecisionKind = | "identity_ambiguous" | "download_stalled" @@ -209,7 +210,7 @@ export type SubscriptionMode = "once" | "continuous"; export type SubscriptionSeasonScope = "current_season" | "following_seasons"; export type SubscriptionOrigin = "user" | "agent" | "policy" | "import"; export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade"; -export type SubscriptionStatus = "active" | "paused" | "completed" | "cancelled"; +export type SubscriptionStatus = "active" | "paused" | "pending" | "completed" | "cancelled"; export type TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead"; export type TasksOpLedgerStatus = "started" | "succeeded"; export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other"; @@ -233,6 +234,8 @@ export interface AcquisitionPlanTable { status_reason: string | null; created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; + unit_keys: Generated; + file_selection: Json | null; } export interface AcquisitionSlotTable { @@ -566,6 +569,10 @@ export interface ContentUnitTable { field_locks: Generated; created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; + episode_total: number | null; + episode_total_source: ContentUnitEpisodeTotalSource | null; + episode_total_locked: Generated; + episode_total_at: Timestamptz | null; } export interface DecisionTable { @@ -1143,6 +1150,29 @@ export interface SiteSnapshotTable { updated_at: GeneratedTimestamptz; } +export interface SlotDeferralTable { + id: Generated; + slot_id: string; + source_release_id: string; + reason: string; + unit_keys: Generated; + attempts: Generated; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface SlotUnitTable { + id: Generated; + slot_id: string; + content_unit_id: string; + media_asset_id: string | null; + plan_id: string | null; + quality: Generated; + pri: number | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + export interface SourceReleaseTable { id: Generated; site_id: string; @@ -1201,6 +1231,23 @@ export interface SubscriptionTable { created_by: string | null; created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; + last_searched_at: Timestamptz | null; + next_search_at: Timestamptz | null; +} + +export interface SubscriptionHistoryTable { + id: Generated; + subscription_id: string | null; + media_entity_id: string | null; + season_unit_id: string | null; + media_kind: string; + mode: string; + reason: string; + snapshot: Generated; + progress: Generated; + completed_at: GeneratedTimestamptz; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; } export interface TaskTable { @@ -1347,9 +1394,12 @@ export interface DB { site: SiteTable; site_account: SiteAccountTable; site_snapshot: SiteSnapshotTable; + slot_deferral: SlotDeferralTable; + slot_unit: SlotUnitTable; source_release: SourceReleaseTable; sse_event: SseEventTable; subscription: SubscriptionTable; + subscription_history: SubscriptionHistoryTable; task: TaskTable; tasks_fact: TasksFactTable; tasks_op_ledger: TasksOpLedgerTable; @@ -1517,6 +1567,12 @@ export type SiteAccountUpdate = Updateable; export type SiteSnapshotRow = Selectable; export type NewSiteSnapshot = Insertable; export type SiteSnapshotUpdate = Updateable; +export type SlotDeferralRow = Selectable; +export type NewSlotDeferral = Insertable; +export type SlotDeferralUpdate = Updateable; +export type SlotUnitRow = Selectable; +export type NewSlotUnit = Insertable; +export type SlotUnitUpdate = Updateable; export type SourceReleaseRow = Selectable; export type NewSourceRelease = Insertable; export type SourceReleaseUpdate = Updateable; @@ -1526,6 +1582,9 @@ export type SseEventUpdate = Updateable; export type SubscriptionRow = Selectable; export type NewSubscription = Insertable; export type SubscriptionUpdate = Updateable; +export type SubscriptionHistoryRow = Selectable; +export type NewSubscriptionHistory = Insertable; +export type SubscriptionHistoryUpdate = Updateable; export type TaskRow = Selectable; export type NewTask = Insertable; export type TaskUpdate = Updateable; diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts index c5577a08..e9c336d4 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -90,6 +90,10 @@ const EXPECTED_TABLES = [ "browser_control_lease", // W5-A 文件管理回收站(0024) "trash_item", + // W5-C 订阅增强(0025) + "slot_deferral", + "slot_unit", + "subscription_history", ].sort(); /** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */ @@ -257,8 +261,10 @@ describe.skipIf(skip)("schema migrations (real PG)", () => { idempotency_key: k, }); await db.insertInto("acquisition_plan").values(plan("k1")).execute(); + // 0025 起剧集 Slot 允许多个单元不相交的在途计划(服务端在 Slot 行锁内按单元校验);幂等键仍唯一 + await db.insertInto("acquisition_plan").values(plan("k2")).execute(); await expect(db.insertInto("acquisition_plan").values(plan("k2")).execute()).rejects.toThrow( - /acquisition_plan_slot_active_key/, + /acquisition_plan_idempotency_key/, ); }); diff --git a/packages/mapping/src/slot.ts b/packages/mapping/src/slot.ts index 4cb3f615..4f0db228 100644 --- a/packages/mapping/src/slot.ts +++ b/packages/mapping/src/slot.ts @@ -4,14 +4,15 @@ import type { SlotStatus as ContractSlotStatus, JsonObject, } from "@umt/contracts"; -import type { - SlotStatus as DomainSlotStatus, - SlotCompletionSpec, - SlotEvaluation, - SlotHardConditions, - SlotSoftPreferences, - SlotSpec, - SlotUpgradePolicy, +import { + type SlotStatus as DomainSlotStatus, + describeViolation, + type SlotCompletionSpec, + type SlotEvaluation, + type SlotHardConditions, + type SlotSoftPreferences, + type SlotSpec, + type SlotUpgradePolicy, } from "@umt/domain"; // Slot:状态两侧取值相同(REQ §6.1),映射表只为让任一侧增删值时编译/测试失败。 @@ -92,26 +93,34 @@ export interface ContractSlotEvaluation { /** 与 DownloadPlanOutput.rejected 同形:硬条件不满足 / 字段未知的原因 */ rejected: { code: string; message: string }[]; reasons: string[]; + /** 优先级阶梯 pri(未配置阶梯为 100;拒绝/未知为 null) */ + pri: number | null; + /** 可读说明(逐条违规 + 逐层阶梯) */ + trace: string[]; } /** domain 评估结果 → 能力输出/决策记录用的扁平表示 */ export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvaluation { + const trace = [...(ev.trace ?? [])]; switch (ev.decision) { case "accept": case "upgrade": - return { decision: ev.decision, acceptable: true, rejected: [], reasons: [...ev.reasons] }; + return { + decision: ev.decision, + acceptable: true, + rejected: [], + reasons: [...ev.reasons], + pri: ev.pri ?? null, + trace, + }; case "reject": return { decision: "reject", acceptable: false, - rejected: ev.violations.map((v) => ({ - code: `${v.field}.${v.code}`, - message: - v.expected === undefined - ? `${v.field}: ${v.code}` - : `${v.field}: ${v.code}(期望 ${JSON.stringify(v.expected)},实际 ${JSON.stringify(v.actual ?? null)})`, - })), + rejected: ev.violations.map((v) => ({ code: `${v.field}.${v.code}`, message: describeViolation(v) })), reasons: [], + pri: ev.ladder?.pri ?? null, + trace, }; case "unknown_field": return { @@ -119,6 +128,8 @@ export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvalua acceptable: false, rejected: ev.missing.map((m) => ({ code: `unknown_field.${m}`, message: `字段未知,需要补充: ${m}` })), reasons: [], + pri: null, + trace, }; } }