7.6 KiB
Context
现有的套餐实名激活逻辑存在两个关联缺陷:
缺陷 A(购买时):order/service.go 的 activateMainPackage 在判断 expiry_base=from_activation 时,无条件将套餐标记为 pending_realname_activation=true(status=0),未检查载体当前是否已实名。这个缺陷造成双重故障:
套餐 status=0(pending)
↓
tryResumeAfterPayment 检查 activatedCount = 0
↓
ResumeCardIfStopped 不触发
↓
① 套餐永久无法激活
② 购买后卡/设备不会自动开机(即使之前因无套餐而停机)
"先实名后购买"是代理商的典型囤货场景(如:设备出厂时已实名,用户激活时购买套餐),在此场景下套餐和开机均失效。
缺陷 B(轮询时):polling_realname_handler.go 的 triggerFirstRealnameActivation 提交的 Asynq 任务硬编码 carrier_type="iot_card",即使卡属于某个设备、该设备有 pending_realname_activation=true 的设备级套餐,也不会触发设备级激活,同样导致设备下的卡无法通过实名事件触发复机。
两个缺陷叠加导致:设备级套餐在"先实名后购买"和"购买后某张卡实名"两个场景均无法激活,且均无法触发自动复机。
现有关键基础设施:
DeviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID)— 通过卡 ID 查关联设备DeviceSimBindingStore.ListByDeviceID(ctx, deviceID)— 查设备绑定的所有卡h.workerResult.Stores.DeviceSimBinding— Worker 中已可用ActivationService.ActivateByRealname(ctx, carrierType, carrierID)— 已支持carrier_type="device",且激活成功后内部会异步调用ResumeCardIfStopped(ct, cid)StopResumeService.ResumeCardIfStopped("device", deviceID)→resumeDeviceCards:遍历设备下因轮询原因停机的卡,逐卡检查实名状态后决定是否复机tryResumeAfterPayment(order service):支付回调成功后,若activatedCount > 0则异步调用ResumeCardIfStopped,支持iot_card和device两种载体类型
复机的 stop_reason 约束(现有设计,本次不变):自动复机仅处理轮询系统引起的停机原因(no_package、traffic_exhausted、not_realname),manual 手动停机不会被自动复机——这是系统的有意设计,防止覆盖人工操作。
Goals / Non-Goals
Goals:
- 购买
from_activation套餐时,若载体当前已实名,直接激活(不进入 pending 状态),同时保障tryResumeAfterPayment的复机链路正常触发 - 卡首次实名时,同时触发其所属设备的设备级套餐激活,进而触发设备下符合条件卡的复机
- 修复后的逻辑统一、无歧义:
from_purchase→ 立即激活;from_activation→ 检查实名再决策(已实名直接激活,未实名等待实名事件)
Non-Goals:
- 不引入新的数据库字段或迁移
- 不改变卡级套餐的现有激活流程
- 不处理历史存量被卡住的套餐(需手动 SQL 修复)
- 不改变孤儿套餐恢复机制(仅处理
from_purchase场景)
Decisions
Decision 1:在 activateMainPackage 中内联实名状态检查
方案 A(选择):在现有 activateMainPackage 的实名决策块(REALNAME-02 段)内扩展逻辑,直接用 tx 查询实名状态。
方案 B:抽取独立 checkCarrierRealnamed(tx, carrierType, carrierID) helper。
选 A 原因:修改点集中在一个函数的一个分支内,变更范围小,逻辑内聚,无需跨文件重构。
实现细节:
iot_card:扩展已有的卡查询,同时SELECT "card_category", "real_name_status",避免额外查询device:新增一次子查询,利用已有的tb_device_sim_binding关系查询是否有已实名的绑定卡:SELECT COUNT(*) FROM tb_iot_card WHERE id IN ( SELECT iot_card_id FROM tb_device_sim_binding WHERE device_id = ? AND bind_status = 1 AND deleted_at IS NULL ) AND real_name_status = 1
Decision 2:在 triggerFirstRealnameActivation 之后添加 triggerDeviceRealnameActivation
方案 A(选择):PollingRealnameHandler 新增 deviceSimBindingStore 字段 + triggerDeviceRealnameActivation 方法,在卡 0→1 时调用。
方案 B:在 PackageActivationHandler.HandlePackageFirstActivation 内部,收到 carrier_type=iot_card 后自动查关联设备再激活设备级套餐。
选 A 原因:语义更清晰——"卡实名"触发"设备套餐激活"属于实名事件的副作用,应由实名 Handler 发起;方案 B 会使激活 Handler 承担实名业务上下文的感知,违反单一职责。
实现细节:triggerDeviceRealnameActivation 调用 deviceSimBindingStore.GetActiveBindingByCardID 获取设备 ID,若无绑定则静默跳过,若有则提交 carrier_type="device" 的 TaskTypePackageFirstActivation 任务。
Decision 3:NewPollingRealnameHandler 构造函数新增参数
直接在参数列表末尾追加 deviceSimBindingStore *postgres.DeviceSimBindingStore,pkg/queue/handler.go 的 registerPollingHandlers 传入已可用的 h.workerResult.Stores.DeviceSimBinding。
Risks / Trade-offs
| 风险 | 缓解措施 |
|---|---|
| 购买时多一次 DB 查询(device 需子查询) | 查询走索引(tb_device_sim_binding.device_id 有索引),影响可忽略 |
| 卡实名时提交多一个 Asynq 任务(device 任务) | 任务幂等:ActivateByRealname 查不到 pending 套餐直接返回 nil,无副作用 |
GetActiveBindingByCardID 返回 ErrRecordNotFound 时不应 log.Error |
已处理:独立卡不属于设备,应静默跳过(Debug/无日志即可) |
| 存量已卡住的套餐(如本次触发排查的 id=1)不会被自动修复 | 在 Migration Plan 中给出手动 SQL |
手动停机(stop_reason=manual)的卡不会被自动复机 |
这是现有系统的有意设计,Fix 1/2 不改变此行为;手动停机需人工操作复机 |
| 历史存量套餐手动修复后,停机卡不会立即自动开机 | 需额外手动调用 Gateway 复机接口,或等待轮询系统下次 EvaluateAndAct 评估(最长等一个轮询周期) |
Migration Plan
存量问题修复(代码部署后手动执行):
-- 找出所有已实名载体下仍处于 pending_realname_activation=true 的套餐
-- 设备级
UPDATE tb_package_usage pu
SET
status = 1,
pending_realname_activation = false,
activated_at = NOW(),
expires_at = NOW() + INTERVAL '12 months', -- 按实际套餐 duration 调整
updated_at = NOW()
WHERE pu.status = 0
AND pu.pending_realname_activation = true
AND pu.device_id > 0
AND pu.device_id IN (
SELECT DISTINCT dsb.device_id
FROM tb_device_sim_binding dsb
JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id
WHERE dsb.bind_status = 1 AND dsb.deleted_at IS NULL
AND ic.real_name_status = 1 AND ic.deleted_at IS NULL
);
-- 卡级
UPDATE tb_package_usage pu
SET
status = 1,
pending_realname_activation = false,
activated_at = NOW(),
expires_at = NOW() + INTERVAL '12 months', -- 按实际套餐 duration 调整
updated_at = NOW()
WHERE pu.status = 0
AND pu.pending_realname_activation = true
AND pu.iot_card_id > 0
AND pu.iot_card_id IN (
SELECT id FROM tb_iot_card WHERE real_name_status = 1 AND deleted_at IS NULL
);
⚠️ 以上 SQL 中
expires_at使用了简化的固定值,生产执行前需根据CalculateExpiryTime逻辑按每条记录的套餐配置计算实际到期时间。
回滚:代码变更纯逻辑,无 schema 变更,直接回滚二进制即可。
Open Questions
无。