Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
140 lines
5.7 KiB
Markdown
140 lines
5.7 KiB
Markdown
# 七月迭代套餐接续恢复
|
||
|
||
## 故障基线
|
||
|
||
生产只读诊断结果为 `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。
|