-- 卡轮询优先队列:新增 tb_polling_priority_item,承载「某卡某轮询任务类型被加急」的活动事实与执行结果。 -- 背景:紧急卡状态查询与普通轮询共用同一条分片调度通道,既无法保证处理顺序,也没有任何地方保存 -- 「这张卡被加急了、因为什么、执行了几次、结果如何」的事实。既有 tb_polling_manual_trigger_log -- 只记录人工批量触发任务本身,不表达逐卡逐任务类型的加急生命周期,因此本迁移新增独立事实表。 -- -- 设计选择: -- 1. 活动项粒度 = (card_id, task_type):同一卡同一轮询任务类型至多一条活动项,由部分唯一索引兜底。 -- 一次加急天然要对多个既有任务类型各执行一次,按类型分行使认领的原子性与逐类型的执行结果审计 -- 都落在单独一行上;按类型互斥也避免「整卡跳过」饿死该卡其它任务类型的普通轮询。 -- 2. 活动集合固定为 pending-待执行 与 processing-执行中;completed-已完成 与 failed-失败出队 为终态。 -- 终态行不占用唯一键,同一卡同一任务类型完成后可再次加急。 -- 3. status 用字符串表达生命周期,取值与 pkg/constants 的 PollingPriorityStatus* 一致。 -- trigger_type 记录首次触发场景(PollingPriorityTrigger*),trigger_types 记录去重后的全部触发场景 -- 集合(英文逗号分隔并两端补分隔符),使「同一活动项先被自动触发、后被人工合并」可追溯。 -- 4. claimed_at 是认领租约的基准时间:执行中且超过认领租约(90 秒,长于既有轮询任务超时 60 秒)的 -- 行允许相邻执行接管领取,避免崩溃遗留的「执行中」让该卡该任务类型被静默永久跳过。 -- 5. attempt_count 只在真正发起执行后累加;未达上限(3 次)的可恢复失败回到 pending 并按既有轮询间隔 -- 重排,达到上限写失败终态后出队;卡不存在、不在轮询范围等业务校验类失败直接进失败终态不重试。 -- 6. shop_id_snapshot 是消费时点解析的卡所属店铺快照(平台卡为 NULL),读侧数据范围按它下推; -- 它是读取用的冗余快照,不承诺与卡当前归属实时一致,卡后续改归属不追溯已入队的加急事实。 -- 7. result 与 failure_reason 只承载可安全对外展示的结果与原因,不写内部错误细节。 -- 8. manual_reason / manual_operator_id / manual_operator_name 记录人工入队的原因与操作者, -- 与事实一同保存;它们不是可维护配置项。 -- 9. 不使用数据库外键;card_id / source_order_id / source_package_usage_id 由应用层显式校验。 -- 10. creator/updater 记录维护账号,系统写入为 0。 CREATE TABLE tb_polling_priority_item ( id BIGSERIAL PRIMARY KEY, card_id BIGINT NOT NULL, task_type VARCHAR(50) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending', trigger_type VARCHAR(30) NOT NULL, trigger_types VARCHAR(255) NOT NULL DEFAULT '', trigger_count INT NOT NULL DEFAULT 1, last_triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), source_order_id BIGINT, source_package_usage_id BIGINT, attempt_count INT NOT NULL DEFAULT 0, claimed_at TIMESTAMPTZ, manual_reason VARCHAR(500) NOT NULL DEFAULT '', manual_operator_id BIGINT NOT NULL DEFAULT 0, manual_operator_name VARCHAR(100) NOT NULL DEFAULT '', shop_id_snapshot BIGINT, result VARCHAR(16) NOT NULL DEFAULT '', failure_reason VARCHAR(500) NOT NULL DEFAULT '', creator BIGINT NOT NULL DEFAULT 0, updater BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ, CONSTRAINT ck_polling_priority_item_card CHECK (card_id > 0), CONSTRAINT ck_polling_priority_item_task_type CHECK (task_type <> ''), CONSTRAINT ck_polling_priority_item_status CHECK (status IN ('pending', 'processing', 'completed', 'failed')), CONSTRAINT ck_polling_priority_item_trigger_type CHECK (trigger_type IN ( 'purchase_activated', 'renewal_activated', 'queue_activated', 'addon_activated', 'no_valid_package', 'manual_trigger' )), CONSTRAINT ck_polling_priority_item_trigger_count CHECK (trigger_count >= 1), CONSTRAINT ck_polling_priority_item_attempt_count CHECK (attempt_count >= 0), CONSTRAINT ck_polling_priority_item_result CHECK (result IN ('', 'success', 'failed')), -- 终态与结果互为充要条件:已完成必须带 success、失败出队必须带 failed, -- 避免出现「已出队却没有结果」的半终态行。非终态行允许保留上次失败结果,便于读侧展示在途重试。 CONSTRAINT ck_polling_priority_item_terminal_result CHECK ( (status = 'completed' AND result = 'success') OR (status = 'failed' AND result = 'failed') OR status IN ('pending', 'processing') ), -- 人工触发必然带操作者;反向不成立(自动触发之后被人工合并的行仍保留自动的首次触发类型)。 CONSTRAINT ck_polling_priority_item_manual_operator CHECK ( trigger_type <> 'manual_trigger' OR manual_operator_id > 0 ) ); -- 活动项唯一键必须是「部分唯一索引」:CREATE TABLE 的表约束不支持 WHERE 谓词, -- 而活动集合与软删感知都要求谓词(软删行与终态行都不得占用键位),因此独立建索引。 -- 该索引不与任何 GORM OnConflict 组合(部分唯一索引谓词未声明时 OnConflict 无法命中,见 KNOWN-ISSUE-001), -- 合并入队由「显式插入 + 23505 唯一冲突识别后转条件更新」实现。 CREATE UNIQUE INDEX uq_polling_priority_item_active ON tb_polling_priority_item (card_id, task_type) WHERE deleted_at IS NULL AND status IN ('pending', 'processing'); -- 读侧列表按状态与时间倒序分页。 CREATE INDEX idx_polling_priority_item_status_created ON tb_polling_priority_item (status, created_at DESC, id DESC) WHERE deleted_at IS NULL; -- 按卡批量探测活动项:轮询执行认领接缝每轮都要走这条路径,必须命中索引而不是全表扫描。 CREATE INDEX idx_polling_priority_item_card ON tb_polling_priority_item (card_id, status) WHERE deleted_at IS NULL; -- 读侧按店铺快照下推数据范围后分页。 CREATE INDEX idx_polling_priority_item_shop_created ON tb_polling_priority_item (shop_id_snapshot, created_at DESC, id DESC) WHERE deleted_at IS NULL; COMMENT ON TABLE tb_polling_priority_item IS '卡轮询优先队列事实表,一个卡一个轮询任务类型至多一条活动项,终态出队后不再占用键位'; COMMENT ON COLUMN tb_polling_priority_item.id IS '主键'; COMMENT ON COLUMN tb_polling_priority_item.card_id IS '优先执行的物联网卡ID,执行对象恒为卡,多卡资产逐卡建项'; COMMENT ON COLUMN tb_polling_priority_item.task_type IS '既有轮询任务类型,取值与 pkg/constants.TaskTypePolling* 一致'; COMMENT ON COLUMN tb_polling_priority_item.status IS '生命周期 pending-待执行 processing-执行中 completed-已完成 failed-失败出队;前两者为活动集合'; COMMENT ON COLUMN tb_polling_priority_item.trigger_type IS '首次触发场景 purchase_activated-主套餐购买后立即生效 renewal_activated-续购生效 queue_activated-排队顺延生效 addon_activated-加油包生效 no_valid_package-资产无有效套餐 manual_trigger-人工入队'; COMMENT ON COLUMN tb_polling_priority_item.trigger_types IS '去重后的全部触发场景集合,英文逗号分隔并两端补分隔符,用于表达自动触发与人工入队的合并'; COMMENT ON COLUMN tb_polling_priority_item.trigger_count IS '累计触发次数,后续触发合并进同一活动项时递增,不新建行也不重复调用'; COMMENT ON COLUMN tb_polling_priority_item.last_triggered_at IS '最近一次触发时间,合并时刷新'; COMMENT ON COLUMN tb_polling_priority_item.source_order_id IS '来源订单ID,自动触发可空,人工入队为空'; COMMENT ON COLUMN tb_polling_priority_item.source_package_usage_id IS '来源套餐使用记录ID,套餐生效类触发可空'; COMMENT ON COLUMN tb_polling_priority_item.attempt_count IS '真实发起执行的次数,只在发起执行后累加,上限为代码常量 3'; COMMENT ON COLUMN tb_polling_priority_item.claimed_at IS '认领时间,认领租约(90 秒)的基准;超过租约的执行中行允许相邻执行接管'; COMMENT ON COLUMN tb_polling_priority_item.manual_reason IS '人工入队原因,人工入队必填,与事实一同保存'; COMMENT ON COLUMN tb_polling_priority_item.manual_operator_id IS '人工入队操作者账号ID,自动触发为 0'; COMMENT ON COLUMN tb_polling_priority_item.manual_operator_name IS '人工入队操作者名称快照,自动触发为空字符串'; COMMENT ON COLUMN tb_polling_priority_item.shop_id_snapshot IS '消费时点解析的卡所属店铺快照,平台卡为 NULL,读侧数据范围按本列下推;卡改归属不追溯已入队的加急事实'; COMMENT ON COLUMN tb_polling_priority_item.result IS '执行结果,终态必须与状态匹配:completed-success failed-failed;非终态为空或上次失败结果'; COMMENT ON COLUMN tb_polling_priority_item.failure_reason IS '可安全对外展示的失败原因,不写内部错误细节'; COMMENT ON COLUMN tb_polling_priority_item.creator IS '创建人账号ID,系统写入为 0'; COMMENT ON COLUMN tb_polling_priority_item.updater IS '最近更新人账号ID,系统写入为 0'; COMMENT ON COLUMN tb_polling_priority_item.created_at IS '创建时间'; COMMENT ON COLUMN tb_polling_priority_item.updated_at IS '最近更新时间'; COMMENT ON COLUMN tb_polling_priority_item.deleted_at IS '软删除时间,正常流程不删除,仅数据清理使用'; COMMENT ON INDEX uq_polling_priority_item_active IS '活动项唯一键(软删与终态感知):一个卡一个轮询任务类型至多一条待执行或执行中的优先项'; COMMENT ON INDEX idx_polling_priority_item_status_created IS '优先队列列表按状态与时间倒序分页索引'; COMMENT ON INDEX idx_polling_priority_item_card IS '轮询执行认领接缝按卡批量探测活动项索引'; COMMENT ON INDEX idx_polling_priority_item_shop_created IS '优先队列列表按店铺快照下推数据范围后的分页索引';