Files
junhong_cmp_fiber/docs/feature-505-package-activation-recovery/功能总结.md
break 5e552d99bc 收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
2026-08-05 14:30:54 +08:00

140 lines
5.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 七月迭代套餐接续恢复
## 故障基线
生产只读诊断结果为 `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。