Files
junhong_cmp_fiber/openspec/changes/fix-realname-activation-logic/design.md
huang 4aab0bcbf2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m39s
提案以及归档
2026-04-18 09:10:29 +08:00

153 lines
7.6 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.
## 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
无。