# 七月迭代套餐接续恢复 ## 故障基线 生产只读诊断结果为 `scanned_count=100`、`skipped_as_occupied=100`、`waiting_realname=0`。旧扫描先截取全库前 100 条待生效套餐,再逐条排除仍有占位套餐的载体,导致窗口之外的真实孤儿永久饥饿。 ## 候选查询 以下 SQL 只读取真实孤儿载体的稳定队首,`EXPLAIN` 版本仅在可访问目标数据库时执行: ```sql EXPLAIN (ANALYZE, BUFFERS) WITH pending_queue AS ( SELECT pending.id, pending.priority, pending.created_at, ROW_NUMBER() OVER ( PARTITION BY CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN 'iot_card' ELSE 'device' END, CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN pending.iot_card_id ELSE pending.device_id END ORDER BY pending.priority ASC, pending.created_at ASC, pending.id ASC ) AS queue_position FROM tb_package_usage AS pending WHERE pending.status = 0 AND pending.master_usage_id IS NULL AND pending.deleted_at IS NULL AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0) AND NOT EXISTS ( SELECT 1 FROM tb_package_usage AS occupied WHERE occupied.status IN (1, 2) AND occupied.master_usage_id IS NULL AND occupied.deleted_at IS NULL AND ( (COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id) OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id) ) ) ) SELECT usage.* FROM pending_queue AS candidate JOIN tb_package_usage AS usage ON usage.id = candidate.id WHERE candidate.queue_position = 1 ORDER BY candidate.priority ASC, candidate.created_at ASC, candidate.id ASC LIMIT 100; ``` 去掉首行 `EXPLAIN (ANALYZE, BUFFERS)` 可查看实际候选。没有查询计划证据前不新增索引。 ## 实现边界 - 旧主套餐及关联加油包先在原事务提交,随后调用 `ActivateNextPendingMainPackage`。 - 孤儿扫描对每个真实孤儿直接调用同一应用能力,不投递排队激活任务。 - 应用能力在新事务内重新检查占位状态、队首与实名条件,并使用载体级 Redis 锁收敛并发。 - 激活状态与稳定的 `card.observation.series.requested` Outbox 在同一事务提交;事件 ID 为 `card-observation:package-usage:{usage_id}:activated`。 - Audit Event、Domain Ledger 新增项和 Integration Log 均为 N/A;`tb_package_usage` 继续作为套餐状态权威事实。 ## 部署前后只读核验 记录真实孤儿数量和最老等待时间: ```sql WITH pending_queue AS ( SELECT pending.id, pending.created_at, ROW_NUMBER() OVER ( PARTITION BY CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN 'iot_card' ELSE 'device' END, CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN pending.iot_card_id ELSE pending.device_id END ORDER BY pending.priority ASC, pending.created_at ASC, pending.id ASC ) AS queue_position FROM tb_package_usage AS pending WHERE pending.status = 0 AND pending.master_usage_id IS NULL AND pending.deleted_at IS NULL AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0) AND NOT EXISTS ( SELECT 1 FROM tb_package_usage AS occupied WHERE occupied.status IN (1, 2) AND occupied.master_usage_id IS NULL AND occupied.deleted_at IS NULL AND ( (COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id) OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id) ) ) ) SELECT COUNT(*) AS orphan_count, MIN(created_at) AS oldest_created_at, NOW() - MIN(created_at) AS oldest_waiting FROM pending_queue WHERE queue_position = 1; ``` 检查同一载体是否存在多条占位主套餐: ```sql SELECT 'iot_card' AS carrier_type, iot_card_id AS carrier_id, COUNT(*) AS occupied_count FROM tb_package_usage WHERE status IN (1, 2) AND master_usage_id IS NULL AND deleted_at IS NULL AND COALESCE(iot_card_id, 0) > 0 GROUP BY iot_card_id HAVING COUNT(*) > 1 UNION ALL SELECT 'device', device_id, COUNT(*) FROM tb_package_usage WHERE status IN (1, 2) AND master_usage_id IS NULL AND deleted_at IS NULL AND COALESCE(iot_card_id, 0) = 0 AND COALESCE(device_id, 0) > 0 GROUP BY device_id HAVING COUNT(*) > 1; ``` 检查卡观测 Outbox 待投递状态: ```sql SELECT status, COUNT(*) AS event_count, MIN(created_at) AS oldest_created_at FROM tb_outbox_event WHERE event_type = 'card.observation.series.requested' GROUP BY status ORDER BY status; ``` 部署后至少观察两个轮询周期。停止观察的条件是:真实孤儿持续收敛、重复占位查询为 0、同步接续成功日志与对应套餐 `status=1` 一致、卡观测 Outbox 正常进入已投递状态。当前环境未连接目标数据库,以上 SQL 的实际结果需在部署窗口记录。 ## 回滚与人工恢复 本次没有数据库迁移。发布后如需回滚,则回退本修复代码并重新部署 Worker;已经正确激活的套餐保持业务事实,不执行反向 SQL。 单卡人工恢复只允许通过 `ActivateNextPendingMainPackage` 按载体类型和 ID 调用,由应用服务重新检查 `status IN (1,2)` 占位、队首、实名条件、Redis 锁及 Outbox 原子写入;禁止直接把 `tb_package_usage.status` 更新为 `1`,避免遗漏激活时间、到期时间、重置时间和卡观测 Outbox。 ## 验证声明 按用户要求,本修复不新增、修改或运行自动化测试。代码验证使用格式化、差异检查、静态检查和全量构建;数据库验证仅使用上述只读 SQL。