Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
5.8 KiB
Context
现有停复机系统存在多条互相依赖的断链:
- stop_reason 不一致:
stopCards(虚流量超额停机路径)写入 DB 时缺少stop_reason和stopped_at,导致ResumeCardIfStopped的stop_reason == "traffic_exhausted"判断永远不通过 - 复机回调缺失:购买套餐(
order/service.go)和流量重置(reset_service.go)后均无复机触发,两者都没有注入StopResumeService的依赖 - Depleted 套餐到期盲区:
findExpiredMainPackages只查status=1,已耗尽(status=2)套餐到期后无人处理,预购的下一个套餐永远卡在 Pending - device 类型无覆盖:
ResumeCardIfStopped及其两处调用点均有carrierType == "iot_card"硬判断,设备类型完全跳过 - 未实名卡无防护:系统自动复机不检查
real_name_status,未实名卡可能被复机 - 并发不安全:流量重置和排队激活可能同时触发同一张卡的
gateway.StartCard
Goals / Non-Goals
Goals:
- 修复所有已识别的停复机断链,使停机卡在满足条件时可靠地自动复机
- 统一
stop_reason写入,所有系统自动停机路径都使用常量 - 设备类型与单卡类型的停复机行为对等
- 未实名卡不被自动复机
- 防止并发 Gateway 调用
Non-Goals:
- 企业设备的
SuspendCard/ResumeCardGateway 补全(独立 Issue) - Gateway + DB 双阶段补偿机制(后期迭代)
- 对人工复机接口增加实名校验(业务上不需要)
Decisions
决策 1:扩展 ResumeCardIfStopped 签名为 (ctx, carrierType, carrierID)
备选方案:新增独立的 ResumeDeviceIfStopped 函数
选择原因:复机的核心逻辑(检查 stop_reason、检查实名状态、加锁、调 Gateway)对两种类型是共用的,只有"查哪张卡"不同。将两者合并到一个函数,只在内部分支处理差异,避免维护两套并行逻辑。调用点签名统一,未来新增类型只改函数内部。
代价:所有现有调用点需要更新传参(当前只有 2 处,成本可控)。
决策 2:SetResumeCallback 模式注入 StopResumeService
备选方案:构造函数直接注入
选择原因:项目中 UsageService 和 ActivationService 已采用此模式,保持一致性。SetResumeCallback 使依赖为可选(nil 检查),不强制要求所有场景都提供复机逻辑,例如纯退款场景的 ActivationService 实例可以不注入。
决策 3:findExpiredMainPackages 改为 status IN (1,2)
备选方案 B:新增独立扫描任务处理 Depleted 套餐
备选方案 C:套餐变 Depleted 时立即激活下一个
选择原因:
- 方案 B 引入重复调度逻辑,两个任务并发时需要额外幂等保护
- 方案 C 改变了状态机触发时机,
expires_at的语义是"到期才交接",提前交接会导致套餐重叠或加油包提前失效 - 方案 A 最小改动,语义与现有
Active→Expired路径完全一致,processExpiredPackage已包含加油包级联失效和下一套餐激活的完整逻辑
加油包处理:当 Depleted 主套餐到达 expires_at 时,加油包一并级联失效(现有 invalidateAddons 逻辑),符合"加油包跟随主套餐生命周期"的业务规则。
决策 4:实名检查在 ResumeCardIfStopped 内部统一执行
备选方案:各调用点分别检查
选择原因:一处检查所有自动复机路径都受保护,不会因新增调用点而遗漏。静默跳过(warn 日志但不返回 error),不影响调用方的正常流程。
决策 5:Redis 分布式锁保护 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 之后(HandlePaymentCallback 的 if err != nil { return } 之后),事务失败不会执行复机;事务成功但复机 Gateway 失败时用户购买了套餐但卡仍停机,属于可接受的降级(有日志可人工介入) |
| device 类型遍历绑定卡时部分卡实名部分未实名 | 内部实名检查在每张卡维度独立执行,已实名卡复机,未实名卡跳过,互不影响 |
| Redis 锁超时(30s)内卡无法再次被复机触发 | 30s 是宽裕值,正常复机操作 < 5s;锁超时后自动释放,不会永久阻塞 |
Migration Plan
无需数据库迁移。本次所有变更均为代码逻辑修复。
部署顺序:直接滚动升级。修复不改变数据库 schema,旧版停机的卡 stop_reason 为空或中文字符串,在 ResumeCardIfStopped 中会命中"非 traffic_exhausted 不复机"的逻辑,行为与现有一致(不会错误复机)。新版停机的卡会写入正确的 stop_reason,才能走自动复机链路。
回滚:直接回滚代码版本,无状态变化需要回滚。
Open Questions
无。所有关键决策已在盘问环节与产品确认。