收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
139
docs/feature-505-package-activation-recovery/功能总结.md
Normal file
139
docs/feature-505-package-activation-recovery/功能总结.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 七月迭代套餐接续恢复
|
||||
|
||||
## 故障基线
|
||||
|
||||
生产只读诊断结果为 `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。
|
||||
Reference in New Issue
Block a user