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

7.6 KiB
Raw Blame History

Context

现有的套餐实名激活逻辑存在两个关联缺陷:

缺陷 A购买时order/service.goactivateMainPackage 在判断 expiry_base=from_activation 时,无条件将套餐标记为 pending_realname_activation=truestatus=0未检查载体当前是否已实名。这个缺陷造成双重故障

套餐 status=0pending
  ↓
tryResumeAfterPayment 检查 activatedCount = 0
  ↓
ResumeCardIfStopped 不触发
  ↓
① 套餐永久无法激活
② 购买后卡/设备不会自动开机(即使之前因无套餐而停机)

"先实名后购买"是代理商的典型囤货场景(如:设备出厂时已实名,用户激活时购买套餐),在此场景下套餐和开机均失效。

缺陷 B轮询时polling_realname_handler.gotriggerFirstRealnameActivation 提交的 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:遍历设备下因轮询原因停机的卡,逐卡检查实名状态后决定是否复机
  • tryResumeAfterPaymentorder service支付回调成功后activatedCount > 0 则异步调用 ResumeCardIfStopped,支持 iot_carddevice 两种载体类型

复机的 stop_reason 约束(现有设计,本次不变):自动复机仅处理轮询系统引起的停机原因(no_packagetraffic_exhaustednot_realnamemanual 手动停机不会被自动复机——这是系统的有意设计,防止覆盖人工操作。


Goals / Non-Goals

Goals:

  • 购买 from_activation 套餐时,若载体当前已实名,直接激活(不进入 pending 状态),同时保障 tryResumeAfterPayment 的复机链路正常触发
  • 卡首次实名时,同时触发其所属设备的设备级套餐激活,进而触发设备下符合条件卡的复机
  • 修复后的逻辑统一、无歧义:from_purchase → 立即激活;from_activation → 检查实名再决策(已实名直接激活,未实名等待实名事件)

Non-Goals:

  • 不引入新的数据库字段或迁移
  • 不改变卡级套餐的现有激活流程
  • 不处理历史存量被卡住的套餐(需手动 SQL 修复)
  • 不改变孤儿套餐恢复机制(仅处理 from_purchase 场景)

Decisions

Decision 1activateMainPackage 中内联实名状态检查

方案 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 2triggerFirstRealnameActivation 之后添加 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 3NewPollingRealnameHandler 构造函数新增参数

直接在参数列表末尾追加 deviceSimBindingStore *postgres.DeviceSimBindingStorepkg/queue/handler.goregisterPollingHandlers 传入已可用的 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

无。