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:
1 parent
d162d464c4
commit
1dd460d359
9 files changed
+405
-34
No files matched your search
@@ -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,
|
||||
),
|
||||
|
||||
@@ -59,6 +59,10 @@ export type Segment = z.infer<typeof Segment>;
|
||||
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
|
||||
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
|
||||
|
||||
/** 季总集数来源:tmdb 元数据 / 插件注入(只升)/ 人工锁定 / 回退被已覆盖最高集兜住 / 沿用 */
|
||||
export const EpisodeTotalSource = z.enum(["tmdb", "injected", "manual", "covered_floor", "previous"]);
|
||||
export type EpisodeTotalSource = z.infer<typeof EpisodeTotalSource>;
|
||||
|
||||
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<typeof ContentUnit>;
|
||||
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<typeof ContentUnitCreate>;
|
||||
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
|
||||
|
||||
@@ -92,7 +92,10 @@ 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 计划 */
|
||||
/**
|
||||
* 下载计划:与 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<typeof AcquisitionPlan>;
|
||||
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<typeof AcquisitionPlanCreate>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -95,6 +95,10 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
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<string, z.ZodEnum> = {
|
||||
BrowserTabStatus: C.BrowserTabStatus,
|
||||
BrowserControlLeaseHolderKind: C.BrowserLeaseHolderKind,
|
||||
BrowserControlLeaseStatus: C.BrowserLeaseStatus,
|
||||
// W5-C(0025)
|
||||
ContentUnitEpisodeTotalSource: C.EpisodeTotalSource,
|
||||
};
|
||||
|
||||
describe("contracts ⇔ db 表结构同步", () => {
|
||||
|
||||
Reference in new issue
Block a user