All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m39s
153 lines
7.6 KiB
Markdown
153 lines
7.6 KiB
Markdown
## 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` 关系查询是否有已实名的绑定卡:
|
||
```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
|
||
|
||
无。
|