feat(db,contracts): W5-C subscription units migration 0025, contracts and mapping

- subscription: status pending (TMDB not ready: searchable/evaluable, not completable),
  last_searched_at / next_search_at
- content_unit (season): episode_total ledger with source / lock / timestamp
- acquisition_plan: unit_keys (Slot × unit reservation) and file_selection; drop the
  one-in-flight-per-slot unique index (checked under slot row lock per unit instead)
- new tables slot_deferral, slot_unit (per-unit quality ledger), subscription_history
- contracts: SubscriptionParams, SubscriptionHistory, SlotDeferral, SlotUnit, SubscriptionProgress,
  EpisodeTotalSet, CandidateEvaluation pri/trace/unitKeys and decision deferred;
  endpoints subscriptions.search/progress/setEpisodeTotal and subscription-history
- mapping: evaluation messages via describeViolation, pri/trace passthrough

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:33:56 +08:00
1 parent d162d464c4
commit 1dd460d359
9 files changed
+405 -34

No files matched your search

+127 -2
View File
@@ -53,7 +53,11 @@ 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"]);
/**
* active 追踪中;paused 暂停(不评估、不搜索);pending TMDB 未就绪(可搜索/评估,不可完成,元数据就绪后转 active);
* completed 已完成(写 subscription_history,行保留可重新激活);cancelled 取消。
*/
export const SubscriptionStatus = z.enum(["active", "paused", "pending", "completed", "cancelled"]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;
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<typeof Subscription>;
/**
* 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<typeof SubscriptionParams>;
export const SubscriptionCreate = Subscription.pick({
mediaEntityId: true,
seasonUnitId: true,
@@ -112,6 +141,96 @@ export const SubscriptionUpdate = Subscription.pick({
}).partial();
export type SubscriptionUpdate = z.infer<typeof SubscriptionUpdate>;
/** 订阅完成历史:完成时写整行快照与进度;订阅行保留,可重新激活 */
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<typeof SubscriptionHistory>;
/** 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<typeof SlotDeferral>;
/** 分集质量账本: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<typeof SlotUnit>;
/** 单元状态(订阅详情"每单元状态") */
export const UnitProgressState = z.enum(["covered", "inflight", "missing", "suspected_missing", "out_of_range"]);
export type UnitProgressState = z.infer<typeof UnitProgressState>;
/** 订阅进度:按季的总集数账本 + 每集状态 + 每 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<typeof SubscriptionProgress>;
/** 手动设置季总集数(只升;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<typeof EpisodeTotalSet>;
/** 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<typeof CandidateEvaluation>;