## 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

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