提案以及归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m39s

This commit is contained in:
2026-04-18 09:10:29 +08:00
parent 6caf0f6141
commit 4aab0bcbf2
40 changed files with 936 additions and 8 deletions

View File

@@ -0,0 +1,152 @@
## Context
现有的套餐实名激活逻辑存在两个关联缺陷:
**缺陷 A购买时**`order/service.go``activateMainPackage` 在判断 `expiry_base=from_activation` 时,无条件将套餐标记为 `pending_realname_activation=true`status=0未检查载体当前是否已实名。这个缺陷造成**双重故障**
```
套餐 status=0pending
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` 关系查询是否有已实名的绑定卡:
```sql
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
**存量问题修复**(代码部署后手动执行):
```sql
-- 找出所有已实名载体下仍处于 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
无。