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: {
|
||||||
"/subscriptions",
|
...crud(
|
||||||
"订阅",
|
"/subscriptions",
|
||||||
{
|
"订阅",
|
||||||
row: policy.Subscription,
|
{
|
||||||
create: policy.SubscriptionCreate,
|
row: policy.Subscription,
|
||||||
update: policy.SubscriptionUpdate,
|
create: policy.SubscriptionCreate,
|
||||||
filters: {
|
update: policy.SubscriptionUpdate,
|
||||||
status: policy.SubscriptionStatus.optional(),
|
filters: {
|
||||||
mediaEntityId: Id.optional(),
|
status: policy.SubscriptionStatus.optional(),
|
||||||
mediaKind: policy.SubscriptionMediaKind.optional(),
|
mediaEntityId: Id.optional(),
|
||||||
/** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */
|
mediaKind: policy.SubscriptionMediaKind.optional(),
|
||||||
q: z.string().max(200).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,
|
R,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export type Segment = z.infer<typeof Segment>;
|
|||||||
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
|
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
|
||||||
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
|
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({
|
export const ContentUnit = EntityBase.extend({
|
||||||
mediaEntityId: Id,
|
mediaEntityId: Id,
|
||||||
parentUnitId: Id.nullable(),
|
parentUnitId: Id.nullable(),
|
||||||
@@ -77,6 +81,12 @@ export const ContentUnit = EntityBase.extend({
|
|||||||
numberingLocked: z.boolean(),
|
numberingLocked: z.boolean(),
|
||||||
metadata: JsonObject,
|
metadata: JsonObject,
|
||||||
fieldLocks: z.array(z.string()),
|
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 type ContentUnit = z.infer<typeof ContentUnit>;
|
||||||
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
|
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
|
||||||
@@ -93,6 +103,10 @@ export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
|
|||||||
numberingLocked: true,
|
numberingLocked: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
fieldLocks: true,
|
fieldLocks: true,
|
||||||
|
episodeTotal: true,
|
||||||
|
episodeTotalSource: true,
|
||||||
|
episodeTotalLocked: true,
|
||||||
|
episodeTotalAt: true,
|
||||||
});
|
});
|
||||||
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
|
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
|
||||||
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
|
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 const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]);
|
||||||
export type PlanStatus = z.infer<typeof PlanStatus>;
|
export type PlanStatus = z.infer<typeof PlanStatus>;
|
||||||
|
|
||||||
/** 下载计划:与 Slot 预留、预算预留同事务创建;一个 Slot 同时只能有一个 reserved/submitted 计划 */
|
/**
|
||||||
|
* 下载计划:与 Slot 预留、预算预留同事务创建。
|
||||||
|
* 剧集 Slot 可同时有多个单元不相交的 reserved/submitted 计划(Slot×单元预留);电影 Slot 同时只有一个。
|
||||||
|
*/
|
||||||
export const AcquisitionPlan = EntityBase.extend({
|
export const AcquisitionPlan = EntityBase.extend({
|
||||||
slotId: Id,
|
slotId: Id,
|
||||||
sourceReleaseId: Id,
|
sourceReleaseId: Id,
|
||||||
@@ -109,6 +112,10 @@ export const AcquisitionPlan = EntityBase.extend({
|
|||||||
idempotencyKey: z.string().min(1),
|
idempotencyKey: z.string().min(1),
|
||||||
expiresAt: Timestamp.nullable(),
|
expiresAt: Timestamp.nullable(),
|
||||||
statusReason: z.string().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 type AcquisitionPlan = z.infer<typeof AcquisitionPlan>;
|
||||||
export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({
|
export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({
|
||||||
@@ -121,6 +128,8 @@ export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, s
|
|||||||
expectedRevision: true,
|
expectedRevision: true,
|
||||||
expiresAt: true,
|
expiresAt: true,
|
||||||
statusReason: true,
|
statusReason: true,
|
||||||
|
unitKeys: true,
|
||||||
|
fileSelection: true,
|
||||||
});
|
});
|
||||||
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
|
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ export type SubscriptionOrigin = z.infer<typeof SubscriptionOrigin>;
|
|||||||
/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */
|
/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */
|
||||||
export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]);
|
export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]);
|
||||||
export type AcquisitionPriority = z.infer<typeof AcquisitionPriority>;
|
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 type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;
|
||||||
|
|
||||||
export const Subscription = EntityBase.extend({
|
export const Subscription = EntityBase.extend({
|
||||||
@@ -72,10 +76,35 @@ export const Subscription = EntityBase.extend({
|
|||||||
statusReason: z.string().nullable(),
|
statusReason: z.string().nullable(),
|
||||||
lastCheckedAt: Timestamp.nullable(),
|
lastCheckedAt: Timestamp.nullable(),
|
||||||
createdBy: z.string().nullable(),
|
createdBy: z.string().nullable(),
|
||||||
|
/** 上次主动搜索时间 / 下次到期(按 params.searchIntervalHours,缺省 24h) */
|
||||||
|
lastSearchedAt: Timestamp.nullable(),
|
||||||
|
nextSearchAt: Timestamp.nullable(),
|
||||||
/** 派生字段(列表返回):作品标题,未建档时为 null */
|
/** 派生字段(列表返回):作品标题,未建档时为 null */
|
||||||
mediaEntityTitle: z.string().nullable().optional(),
|
mediaEntityTitle: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type Subscription = z.infer<typeof Subscription>;
|
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({
|
export const SubscriptionCreate = Subscription.pick({
|
||||||
mediaEntityId: true,
|
mediaEntityId: true,
|
||||||
seasonUnitId: true,
|
seasonUnitId: true,
|
||||||
@@ -112,6 +141,96 @@ export const SubscriptionUpdate = Subscription.pick({
|
|||||||
}).partial();
|
}).partial();
|
||||||
export type SubscriptionUpdate = z.infer<typeof SubscriptionUpdate>;
|
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) */
|
/** Slot 状态(REQ §6.1) */
|
||||||
export const SlotStatus = z.enum([
|
export const SlotStatus = z.enum([
|
||||||
"empty",
|
"empty",
|
||||||
@@ -351,11 +470,17 @@ export const CandidateEvaluation = z.object({
|
|||||||
sourceReleaseId: Id,
|
sourceReleaseId: Id,
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
/** accept / upgrade / reject / unknown_field / skipped(被模式、预算、阈值、总开关等挡住) */
|
/** 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(),
|
mode: z.enum(["new_title", "catch_up", "upgrade"]).nullable(),
|
||||||
rejected: z.array(z.object({ code: z.string(), message: z.string() })),
|
rejected: z.array(z.object({ code: z.string(), message: z.string() })),
|
||||||
reasons: z.array(z.string()),
|
reasons: z.array(z.string()),
|
||||||
planId: Id.nullable(),
|
planId: Id.nullable(),
|
||||||
evaluatedAt: Timestamp,
|
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>;
|
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 },
|
BrowserControlLease: { row: C.BrowserControlLease },
|
||||||
// W5-A 文件管理回收站(0024)
|
// W5-A 文件管理回收站(0024)
|
||||||
TrashItem: { row: C.TrashItemRow },
|
TrashItem: { row: C.TrashItemRow },
|
||||||
|
// W5-C 订阅增强(0025)
|
||||||
|
SlotDeferral: { row: C.SlotDeferral },
|
||||||
|
SlotUnit: { row: C.SlotUnit },
|
||||||
|
SubscriptionHistory: { row: C.SubscriptionHistory },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */
|
/** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */
|
||||||
@@ -223,6 +227,8 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
|||||||
BrowserTabStatus: C.BrowserTabStatus,
|
BrowserTabStatus: C.BrowserTabStatus,
|
||||||
BrowserControlLeaseHolderKind: C.BrowserLeaseHolderKind,
|
BrowserControlLeaseHolderKind: C.BrowserLeaseHolderKind,
|
||||||
BrowserControlLeaseStatus: C.BrowserLeaseStatus,
|
BrowserControlLeaseStatus: C.BrowserLeaseStatus,
|
||||||
|
// W5-C(0025)
|
||||||
|
ContentUnitEpisodeTotalSource: C.EpisodeTotalSource,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("contracts ⇔ db 表结构同步", () => {
|
describe("contracts ⇔ db 表结构同步", () => {
|
||||||
|
|||||||
@@ -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<any>): Promise<void> {
|
||||||
|
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<any>): Promise<void> {
|
||||||
|
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"])};
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ export type ContentUnitContentType =
|
|||||||
| "unknown";
|
| "unknown";
|
||||||
export type ContentUnitSegment = "full" | "upper" | "middle" | "lower" | "unknown";
|
export type ContentUnitSegment = "full" | "upper" | "middle" | "lower" | "unknown";
|
||||||
export type ContentUnitStatus = "provisional" | "confirmed" | "retired";
|
export type ContentUnitStatus = "provisional" | "confirmed" | "retired";
|
||||||
|
export type ContentUnitEpisodeTotalSource = "tmdb" | "injected" | "manual" | "covered_floor" | "previous";
|
||||||
export type DecisionKind =
|
export type DecisionKind =
|
||||||
| "identity_ambiguous"
|
| "identity_ambiguous"
|
||||||
| "download_stalled"
|
| "download_stalled"
|
||||||
@@ -209,7 +210,7 @@ export type SubscriptionMode = "once" | "continuous";
|
|||||||
export type SubscriptionSeasonScope = "current_season" | "following_seasons";
|
export type SubscriptionSeasonScope = "current_season" | "following_seasons";
|
||||||
export type SubscriptionOrigin = "user" | "agent" | "policy" | "import";
|
export type SubscriptionOrigin = "user" | "agent" | "policy" | "import";
|
||||||
export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade";
|
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 TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead";
|
||||||
export type TasksOpLedgerStatus = "started" | "succeeded";
|
export type TasksOpLedgerStatus = "started" | "succeeded";
|
||||||
export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other";
|
export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other";
|
||||||
@@ -233,6 +234,8 @@ export interface AcquisitionPlanTable {
|
|||||||
status_reason: string | null;
|
status_reason: string | null;
|
||||||
created_at: GeneratedTimestamptz;
|
created_at: GeneratedTimestamptz;
|
||||||
updated_at: GeneratedTimestamptz;
|
updated_at: GeneratedTimestamptz;
|
||||||
|
unit_keys: Generated<string[]>;
|
||||||
|
file_selection: Json | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcquisitionSlotTable {
|
export interface AcquisitionSlotTable {
|
||||||
@@ -566,6 +569,10 @@ export interface ContentUnitTable {
|
|||||||
field_locks: Generated<string[]>;
|
field_locks: Generated<string[]>;
|
||||||
created_at: GeneratedTimestamptz;
|
created_at: GeneratedTimestamptz;
|
||||||
updated_at: GeneratedTimestamptz;
|
updated_at: GeneratedTimestamptz;
|
||||||
|
episode_total: number | null;
|
||||||
|
episode_total_source: ContentUnitEpisodeTotalSource | null;
|
||||||
|
episode_total_locked: Generated<boolean>;
|
||||||
|
episode_total_at: Timestamptz | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecisionTable {
|
export interface DecisionTable {
|
||||||
@@ -1143,6 +1150,29 @@ export interface SiteSnapshotTable {
|
|||||||
updated_at: GeneratedTimestamptz;
|
updated_at: GeneratedTimestamptz;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SlotDeferralTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
slot_id: string;
|
||||||
|
source_release_id: string;
|
||||||
|
reason: string;
|
||||||
|
unit_keys: Generated<string[]>;
|
||||||
|
attempts: Generated<number>;
|
||||||
|
created_at: GeneratedTimestamptz;
|
||||||
|
updated_at: GeneratedTimestamptz;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotUnitTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
slot_id: string;
|
||||||
|
content_unit_id: string;
|
||||||
|
media_asset_id: string | null;
|
||||||
|
plan_id: string | null;
|
||||||
|
quality: Generated<Json>;
|
||||||
|
pri: number | null;
|
||||||
|
created_at: GeneratedTimestamptz;
|
||||||
|
updated_at: GeneratedTimestamptz;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SourceReleaseTable {
|
export interface SourceReleaseTable {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
site_id: string;
|
site_id: string;
|
||||||
@@ -1201,6 +1231,23 @@ export interface SubscriptionTable {
|
|||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
created_at: GeneratedTimestamptz;
|
created_at: GeneratedTimestamptz;
|
||||||
updated_at: GeneratedTimestamptz;
|
updated_at: GeneratedTimestamptz;
|
||||||
|
last_searched_at: Timestamptz | null;
|
||||||
|
next_search_at: Timestamptz | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionHistoryTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
subscription_id: string | null;
|
||||||
|
media_entity_id: string | null;
|
||||||
|
season_unit_id: string | null;
|
||||||
|
media_kind: string;
|
||||||
|
mode: string;
|
||||||
|
reason: string;
|
||||||
|
snapshot: Generated<Json>;
|
||||||
|
progress: Generated<Json>;
|
||||||
|
completed_at: GeneratedTimestamptz;
|
||||||
|
created_at: GeneratedTimestamptz;
|
||||||
|
updated_at: GeneratedTimestamptz;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskTable {
|
export interface TaskTable {
|
||||||
@@ -1347,9 +1394,12 @@ export interface DB {
|
|||||||
site: SiteTable;
|
site: SiteTable;
|
||||||
site_account: SiteAccountTable;
|
site_account: SiteAccountTable;
|
||||||
site_snapshot: SiteSnapshotTable;
|
site_snapshot: SiteSnapshotTable;
|
||||||
|
slot_deferral: SlotDeferralTable;
|
||||||
|
slot_unit: SlotUnitTable;
|
||||||
source_release: SourceReleaseTable;
|
source_release: SourceReleaseTable;
|
||||||
sse_event: SseEventTable;
|
sse_event: SseEventTable;
|
||||||
subscription: SubscriptionTable;
|
subscription: SubscriptionTable;
|
||||||
|
subscription_history: SubscriptionHistoryTable;
|
||||||
task: TaskTable;
|
task: TaskTable;
|
||||||
tasks_fact: TasksFactTable;
|
tasks_fact: TasksFactTable;
|
||||||
tasks_op_ledger: TasksOpLedgerTable;
|
tasks_op_ledger: TasksOpLedgerTable;
|
||||||
@@ -1517,6 +1567,12 @@ export type SiteAccountUpdate = Updateable<SiteAccountTable>;
|
|||||||
export type SiteSnapshotRow = Selectable<SiteSnapshotTable>;
|
export type SiteSnapshotRow = Selectable<SiteSnapshotTable>;
|
||||||
export type NewSiteSnapshot = Insertable<SiteSnapshotTable>;
|
export type NewSiteSnapshot = Insertable<SiteSnapshotTable>;
|
||||||
export type SiteSnapshotUpdate = Updateable<SiteSnapshotTable>;
|
export type SiteSnapshotUpdate = Updateable<SiteSnapshotTable>;
|
||||||
|
export type SlotDeferralRow = Selectable<SlotDeferralTable>;
|
||||||
|
export type NewSlotDeferral = Insertable<SlotDeferralTable>;
|
||||||
|
export type SlotDeferralUpdate = Updateable<SlotDeferralTable>;
|
||||||
|
export type SlotUnitRow = Selectable<SlotUnitTable>;
|
||||||
|
export type NewSlotUnit = Insertable<SlotUnitTable>;
|
||||||
|
export type SlotUnitUpdate = Updateable<SlotUnitTable>;
|
||||||
export type SourceReleaseRow = Selectable<SourceReleaseTable>;
|
export type SourceReleaseRow = Selectable<SourceReleaseTable>;
|
||||||
export type NewSourceRelease = Insertable<SourceReleaseTable>;
|
export type NewSourceRelease = Insertable<SourceReleaseTable>;
|
||||||
export type SourceReleaseUpdate = Updateable<SourceReleaseTable>;
|
export type SourceReleaseUpdate = Updateable<SourceReleaseTable>;
|
||||||
@@ -1526,6 +1582,9 @@ export type SseEventUpdate = Updateable<SseEventTable>;
|
|||||||
export type SubscriptionRow = Selectable<SubscriptionTable>;
|
export type SubscriptionRow = Selectable<SubscriptionTable>;
|
||||||
export type NewSubscription = Insertable<SubscriptionTable>;
|
export type NewSubscription = Insertable<SubscriptionTable>;
|
||||||
export type SubscriptionUpdate = Updateable<SubscriptionTable>;
|
export type SubscriptionUpdate = Updateable<SubscriptionTable>;
|
||||||
|
export type SubscriptionHistoryRow = Selectable<SubscriptionHistoryTable>;
|
||||||
|
export type NewSubscriptionHistory = Insertable<SubscriptionHistoryTable>;
|
||||||
|
export type SubscriptionHistoryUpdate = Updateable<SubscriptionHistoryTable>;
|
||||||
export type TaskRow = Selectable<TaskTable>;
|
export type TaskRow = Selectable<TaskTable>;
|
||||||
export type NewTask = Insertable<TaskTable>;
|
export type NewTask = Insertable<TaskTable>;
|
||||||
export type TaskUpdate = Updateable<TaskTable>;
|
export type TaskUpdate = Updateable<TaskTable>;
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ const EXPECTED_TABLES = [
|
|||||||
"browser_control_lease",
|
"browser_control_lease",
|
||||||
// W5-A 文件管理回收站(0024)
|
// W5-A 文件管理回收站(0024)
|
||||||
"trash_item",
|
"trash_item",
|
||||||
|
// W5-C 订阅增强(0025)
|
||||||
|
"slot_deferral",
|
||||||
|
"slot_unit",
|
||||||
|
"subscription_history",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
||||||
@@ -257,8 +261,10 @@ describe.skipIf(skip)("schema migrations (real PG)", () => {
|
|||||||
idempotency_key: k,
|
idempotency_key: k,
|
||||||
});
|
});
|
||||||
await db.insertInto("acquisition_plan").values(plan("k1")).execute();
|
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(
|
await expect(db.insertInto("acquisition_plan").values(plan("k2")).execute()).rejects.toThrow(
|
||||||
/acquisition_plan_slot_active_key/,
|
/acquisition_plan_idempotency_key/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import type {
|
|||||||
SlotStatus as ContractSlotStatus,
|
SlotStatus as ContractSlotStatus,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import type {
|
import {
|
||||||
SlotStatus as DomainSlotStatus,
|
type SlotStatus as DomainSlotStatus,
|
||||||
SlotCompletionSpec,
|
describeViolation,
|
||||||
SlotEvaluation,
|
type SlotCompletionSpec,
|
||||||
SlotHardConditions,
|
type SlotEvaluation,
|
||||||
SlotSoftPreferences,
|
type SlotHardConditions,
|
||||||
SlotSpec,
|
type SlotSoftPreferences,
|
||||||
SlotUpgradePolicy,
|
type SlotSpec,
|
||||||
|
type SlotUpgradePolicy,
|
||||||
} from "@umt/domain";
|
} from "@umt/domain";
|
||||||
|
|
||||||
// Slot:状态两侧取值相同(REQ §6.1),映射表只为让任一侧增删值时编译/测试失败。
|
// Slot:状态两侧取值相同(REQ §6.1),映射表只为让任一侧增删值时编译/测试失败。
|
||||||
@@ -92,26 +93,34 @@ export interface ContractSlotEvaluation {
|
|||||||
/** 与 DownloadPlanOutput.rejected 同形:硬条件不满足 / 字段未知的原因 */
|
/** 与 DownloadPlanOutput.rejected 同形:硬条件不满足 / 字段未知的原因 */
|
||||||
rejected: { code: string; message: string }[];
|
rejected: { code: string; message: string }[];
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
|
/** 优先级阶梯 pri(未配置阶梯为 100;拒绝/未知为 null) */
|
||||||
|
pri: number | null;
|
||||||
|
/** 可读说明(逐条违规 + 逐层阶梯) */
|
||||||
|
trace: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** domain 评估结果 → 能力输出/决策记录用的扁平表示 */
|
/** domain 评估结果 → 能力输出/决策记录用的扁平表示 */
|
||||||
export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvaluation {
|
export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvaluation {
|
||||||
|
const trace = [...(ev.trace ?? [])];
|
||||||
switch (ev.decision) {
|
switch (ev.decision) {
|
||||||
case "accept":
|
case "accept":
|
||||||
case "upgrade":
|
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":
|
case "reject":
|
||||||
return {
|
return {
|
||||||
decision: "reject",
|
decision: "reject",
|
||||||
acceptable: false,
|
acceptable: false,
|
||||||
rejected: ev.violations.map((v) => ({
|
rejected: ev.violations.map((v) => ({ code: `${v.field}.${v.code}`, message: describeViolation(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)})`,
|
|
||||||
})),
|
|
||||||
reasons: [],
|
reasons: [],
|
||||||
|
pri: ev.ladder?.pri ?? null,
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
case "unknown_field":
|
case "unknown_field":
|
||||||
return {
|
return {
|
||||||
@@ -119,6 +128,8 @@ export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvalua
|
|||||||
acceptable: false,
|
acceptable: false,
|
||||||
rejected: ev.missing.map((m) => ({ code: `unknown_field.${m}`, message: `字段未知,需要补充: ${m}` })),
|
rejected: ev.missing.map((m) => ({ code: `unknown_field.${m}`, message: `字段未知,需要补充: ${m}` })),
|
||||||
reasons: [],
|
reasons: [],
|
||||||
|
pri: null,
|
||||||
|
trace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user