Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
82 lines
5.8 KiB
Markdown
82 lines
5.8 KiB
Markdown
## Context
|
||
|
||
现有停复机系统存在多条互相依赖的断链:
|
||
|
||
1. **stop_reason 不一致**:`stopCards`(虚流量超额停机路径)写入 DB 时缺少 `stop_reason` 和 `stopped_at`,导致 `ResumeCardIfStopped` 的 `stop_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 处,成本可控)。
|
||
|
||
### 决策 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
|
||
|
||
无。所有关键决策已在盘问环节与产品确认。
|