Files
junhong_cmp_fiber/openspec/changes/archive/2026-03-31-fix-stop-resume-lifecycle-engine/design.md

5.8 KiB
Raw Blame History

Context

现有停复机系统存在多条互相依赖的断链:

  1. stop_reason 不一致stopCards(虚流量超额停机路径)写入 DB 时缺少 stop_reasonstopped_at,导致 ResumeCardIfStoppedstop_reason == "traffic_exhausted" 判断永远不通过
  2. 复机回调缺失:购买套餐(order/service.go)和流量重置(reset_service.go)后均无复机触发,两者都没有注入 StopResumeService 的依赖
  3. Depleted 套餐到期盲区findExpiredMainPackages 只查 status=1,已耗尽(status=2)套餐到期后无人处理,预购的下一个套餐永远卡在 Pending
  4. device 类型无覆盖ResumeCardIfStopped 及其两处调用点均有 carrierType == "iot_card" 硬判断,设备类型完全跳过
  5. 未实名卡无防护:系统自动复机不检查 real_name_status,未实名卡可能被复机
  6. 并发不安全:流量重置和排队激活可能同时触发同一张卡的 gateway.StartCard

Goals / Non-Goals

Goals:

  • 修复所有已识别的停复机断链,使停机卡在满足条件时可靠地自动复机
  • 统一 stop_reason 写入,所有系统自动停机路径都使用常量
  • 设备类型与单卡类型的停复机行为对等
  • 未实名卡不被自动复机
  • 防止并发 Gateway 调用

Non-Goals:

  • 企业设备的 SuspendCard/ResumeCard Gateway 补全(独立 Issue
  • Gateway + DB 双阶段补偿机制(后期迭代)
  • 对人工复机接口增加实名校验(业务上不需要)

Decisions

决策 1扩展 ResumeCardIfStopped 签名为 (ctx, carrierType, carrierID)

备选方案:新增独立的 ResumeDeviceIfStopped 函数
选择原因:复机的核心逻辑(检查 stop_reason、检查实名状态、加锁、调 Gateway对两种类型是共用的只有"查哪张卡"不同。将两者合并到一个函数,只在内部分支处理差异,避免维护两套并行逻辑。调用点签名统一,未来新增类型只改函数内部。
代价:所有现有调用点需要更新传参(当前只有 2 处,成本可控)。

决策 2SetResumeCallback 模式注入 StopResumeService

备选方案:构造函数直接注入
选择原因:项目中 UsageServiceActivationService 已采用此模式,保持一致性。SetResumeCallback 使依赖为可选nil 检查),不强制要求所有场景都提供复机逻辑,例如纯退款场景的 ActivationService 实例可以不注入。

决策 3findExpiredMainPackages 改为 status IN (1,2)

备选方案 B:新增独立扫描任务处理 Depleted 套餐
备选方案 C:套餐变 Depleted 时立即激活下一个
选择原因

  • 方案 B 引入重复调度逻辑,两个任务并发时需要额外幂等保护
  • 方案 C 改变了状态机触发时机,expires_at 的语义是"到期才交接",提前交接会导致套餐重叠或加油包提前失效
  • 方案 A 最小改动,语义与现有 Active→Expired 路径完全一致,processExpiredPackage 已包含加油包级联失效和下一套餐激活的完整逻辑

加油包处理:当 Depleted 主套餐到达 expires_at 时,加油包一并级联失效(现有 invalidateAddons 逻辑),符合"加油包跟随主套餐生命周期"的业务规则。

决策 4实名检查在 ResumeCardIfStopped 内部统一执行

备选方案:各调用点分别检查
选择原因一处检查所有自动复机路径都受保护不会因新增调用点而遗漏。静默跳过warn 日志但不返回 error不影响调用方的正常流程。

决策 5Redis 分布式锁保护 ResumeCardIfStopped

Key 格式:card:resume:lock:{cardID}TTL 30s。
背景:流量重置和排队激活有可能在同一时间窗口内对同一张卡触发复机。重复 gateway.StartCard 后果可控(运营商幂等),但增加无效 Gateway 调用和日志噪音。Redis 锁以最低成本解决此问题。
加锁失败(锁被占用)时直接返回,不排队等待——说明正在复机,本次复机请求可丢弃。

Risks / Trade-offs

风险 缓解措施
ResumeCardIfStopped 签名变更导致编译错误 直接用 lsp_diagnostics 找所有调用点,修改完后编译验证
findExpiredMainPackages 扩展后 Depleted 套餐被重复处理 processExpiredPackage 内部先更新状态为 Expired,幂等性由 expires_at <= now AND status IN (1,2) 条件查询保证,相同套餐不会被重复捞起
order/service.go 复机调用在事务外执行,事务失败但复机已触发 复机调用在事务 commit 之后HandlePaymentCallbackif err != nil { return } 之后),事务失败不会执行复机;事务成功但复机 Gateway 失败时用户购买了套餐但卡仍停机,属于可接受的降级(有日志可人工介入)
device 类型遍历绑定卡时部分卡实名部分未实名 内部实名检查在每张卡维度独立执行,已实名卡复机,未实名卡跳过,互不影响
Redis 锁超时30s内卡无法再次被复机触发 30s 是宽裕值,正常复机操作 < 5s锁超时后自动释放不会永久阻塞

Migration Plan

无需数据库迁移。本次所有变更均为代码逻辑修复。

部署顺序:直接滚动升级。修复不改变数据库 schema旧版停机的卡 stop_reason 为空或中文字符串,在 ResumeCardIfStopped 中会命中"非 traffic_exhausted 不复机"的逻辑,行为与现有一致(不会错误复机)。新版停机的卡会写入正确的 stop_reason,才能走自动复机链路。

回滚:直接回滚代码版本,无状态变化需要回滚。

Open Questions

无。所有关键决策已在盘问环节与产品确认。