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

82 lines
5.8 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
现有停复机系统存在多条互相依赖的断链:
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 处,成本可控)。
### 决策 2SetResumeCallback 模式注入 StopResumeService
**备选方案**:构造函数直接注入
**选择原因**:项目中 `UsageService``ActivationService` 已采用此模式,保持一致性。`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 之后**`HandlePaymentCallback``if err != nil { return }` 之后),事务失败不会执行复机;事务成功但复机 Gateway 失败时用户购买了套餐但卡仍停机,属于可接受的降级(有日志可人工介入) |
| device 类型遍历绑定卡时部分卡实名部分未实名 | 内部实名检查在每张卡维度独立执行,已实名卡复机,未实名卡跳过,互不影响 |
| Redis 锁超时30s内卡无法再次被复机触发 | 30s 是宽裕值,正常复机操作 < 5s锁超时后自动释放不会永久阻塞 |
## Migration Plan
**无需数据库迁移**。本次所有变更均为代码逻辑修复。
**部署顺序**:直接滚动升级。修复不改变数据库 schema旧版停机的卡 `stop_reason` 为空或中文字符串,在 `ResumeCardIfStopped` 中会命中"非 traffic_exhausted 不复机"的逻辑,行为与现有一致(不会错误复机)。新版停机的卡会写入正确的 `stop_reason`,才能走自动复机链路。
**回滚**:直接回滚代码版本,无状态变化需要回滚。
## Open Questions
无。所有关键决策已在盘问环节与产品确认。