## Context

当前系统存在两类根本性缺陷，且已在生产数据中造成可见损坏。

**时间零值问题根因**：GORM 对非指针 `time.Time` 字段的处理规则是"任何值（包括零值）都写入数据库"，而 `*time.Time` 为 nil 时写 NULL。`PackageUsage.ActivatedAt/ExpiresAt` 定义为 `time.Time`（NOT NULL），当套餐处于待生效状态（status=0）时，代码主动赋值 `time.Time{}` 规避 GORM 的零值跳过逻辑，导致 `0001-01-01 00:00:00 UTC` 被持久化，在中国历史时区下显示为 `0001-01-01 00:00:43`。其余三个字段（LastUsedAt × 2、VerifiedAt）的 DB 列已是 nullable，但 Go 类型仍是非指针，同样会写零值。

**激活逻辑根因**：系统有两套激活路径但职责边界混乱：
- `HandlePackageActivationCheck`（轮询，每 10 秒）：负责检测过期、提交激活任务
- `HandlePackageQueueActivation`（Asynq 任务）：负责执行具体激活

问题在于 `HandlePackageQueueActivation` 调用了 `ActivateQueuedPackage(carrierType, carrierID)`，该函数的设计意图是"扫描过期包→标记过期→激活下一个"的完整循环，但轮询处理器已经完成了"标记过期"步骤。Asynq 任务运行时，过期触发条件已消失，函数空转返回，payload 中携带的目标 `PackageUsageID` 被完全浪费。

另外，系统无任何机制感知"载体无生效套餐但有待生效套餐"的孤儿状态，重启后 Asynq 任务丢失或 MaxRetry 耗尽后状态永久卡死。

## Goals / Non-Goals

**Goals:**
- 彻底消除时间零值污染：5 个字段全部改为 `*time.Time`，DB 列对齐，历史数据清洗
- 修复激活链断链：Asynq 任务直接激活 payload 指定的目标套餐，不再重跑发现流程
- 建立孤儿套餐自愈机制：10 秒周期内兜底检测无主套餐但有待激活套餐的载体，覆盖重启恢复和任务丢失两种场景
- 不影响已生效（status=1）和已过期（status=3）套餐的任何行为

**Non-Goals:**
- 不重构 Asynq 任务队列架构
- 不修改套餐购买、定价、佣金等其他流程
- 不处理 `pending_realname_activation=true` 的套餐（实名激活路径单独维护，无此问题）
- 不引入新的外部依赖

## Decisions

### 决策 1：`ActivateSpecificPackage` 独立方法，而非修改 `ActivateQueuedPackage`

**选择**：在 `ActivationService` 新增 `ActivateSpecificPackage(ctx, packageUsageID uint)` 方法，`HandlePackageQueueActivation` 改为调用该方法。

**理由**：`ActivateQueuedPackage` 是"发现+执行"的组合操作，修改它会影响所有调用方；新方法职责单一——"激活一个已知 ID 的套餐"——符合最小改动原则，不破坏其他调用路径。

**`ActivateSpecificPackage` 逻辑**：
```
1. 加载 PackageUsage（根据 ID）
2. 幂等检查：status != Pending → 直接返回
3. 加载关联 Package（获取 CalendarType, DurationMonths/Days, DataResetCycle）
4. activatedAt = now，计算 expiresAt 和 nextResetAt
5. 事务内更新：status=1, activated_at, expires_at, [next_reset_at]
6. 调用 syncCarrierStatusActivated
7. 异步调用 resumeCallback.ResumeCardIfStopped（如已注入）
```

**备选方案**：直接修改 `ActivateQueuedPackage` 接受 packageUsageID 参数 → 拒绝，会改变该函数对其他调用方的契约。

---

### 决策 2：孤儿扫描合并到 `HandlePackageActivationCheck` 现有周期

**选择**：在 `HandlePackageActivationCheck` 末尾追加孤儿扫描逻辑（独立函数 `findAndActivateOrphanPackages`），与现有过期检测复用同一 10 秒 ticker。

**孤儿定义**（伪代码 SQL，仅描述查询语义；实现时需使用相关子查询将外层表别名化，`outer` 为示意，不可直接执行）：
```sql
-- 查找"无生效套餐但有待激活套餐"的载体
-- 注意：实现时外层表需显式指定别名（如 p1），子查询中通过 p1.iot_card_id 关联
SELECT DISTINCT carrier_type, carrier_id
FROM tb_package_usage AS p1
WHERE p1.status = 0
  AND p1.master_usage_id IS NULL
  AND p1.pending_realname_activation = false
  AND p1.deleted_at IS NULL
  AND NOT EXISTS (
    SELECT 1 FROM tb_package_usage p2
    WHERE p2.status = 1
      AND p2.master_usage_id IS NULL
      AND p2.deleted_at IS NULL
      AND (
        (p1.iot_card_id > 0 AND p2.iot_card_id = p1.iot_card_id)
        OR (p1.device_id > 0 AND p2.device_id = p1.device_id)
      )
  )
```

**理由**：孤儿状态与过期状态在时间维度上相关，合并到同一检查周期减少代码分散；10 秒间隔满足"重启后 10 秒内自动恢复"的 SLA。

**备选方案**：独立 goroutine 或独立 Asynq Scheduler 任务 → 拒绝，增加组件复杂度，10 秒已足够。

---

### 决策 3：`*time.Time` 指针方案，不使用 `sql.NullTime`

**选择**：所有受影响字段改为 `*time.Time`。

**理由**：项目已有大量 `*time.Time` 用法（`Order.PaidAt`、`Device.ActivatedAt` 等），风格一致；`sql.NullTime` 引入 `Valid` 字段读写噪音，不符合项目现有模式。

---

### 决策 4：数据迁移策略

`tb_package_usage.activated_at/expires_at` 的处理分两步：
1. **数据清洗**（先执行）：`UPDATE tb_package_usage SET activated_at = NULL, expires_at = NULL WHERE status = 0 AND activated_at < '2000-01-01'`（仅清洗零值，不影响已激活记录）
2. **结构变更**（后执行）：`ALTER TABLE tb_package_usage ALTER COLUMN activated_at DROP NOT NULL`

顺序原因：先清洗确保 NOT NULL 约束变更后不遗留脏数据；两步均可独立回滚。

## Risks / Trade-offs

**[风险] 孤儿扫描的 N+1 性能问题** → **缓解**：限制单次扫描最多处理 100 个孤儿载体；孤儿状态在正常业务中极少发生（仅重启恢复和任务失败时），不会成为热路径。

**[风险] `ActivateSpecificPackage` 并发激活同一套餐** → **缓解**：复用已有 `RedisPackageActivationLockKey(carrierType, carrierID)` 分布式锁 + 幂等检查（status != Pending 直接返回）。

**[风险] `*time.Time` 改动引发下游编译错误** → **缓解**：修改后运行 `go build ./...` 全量编译，统一修复所有 nil 判断。

**[权衡] 历史零值数据一旦清为 NULL，无法区分"未激活"和"数据损坏"** → 可接受：所有 status=0 且 `activated_at` 为零值的记录语义上就是"未激活"，NULL 是正确表达。

## Migration Plan

1. **部署前**：执行数据清洗 SQL（修复 9 条零值记录 → NULL）
2. **部署**：滚动发布（DB 列仍 NOT NULL，旧代码已用 `Omit` 跳过写入，新代码写 NULL 不兼容）→ **必须作为单次停机升级**，先跑迁移再升级服务
3. **迁移顺序**：
   - Step 1: 执行数据清洗 SQL
   - Step 2: 执行 ALTER TABLE（DROP NOT NULL）
   - Step 3: 部署新代码
4. **回滚**：若部署失败，先回滚代码（旧代码写零值，DB 已允许 NULL，业务可继续），再评估是否需回滚 DB 结构（通常不需要）

## Open Questions

- 孤儿扫描触发激活后，是否需要记录审计日志？（建议：记录 Info 日志即可，不进审计表）
- `ActivateSpecificPackage` 失败时（如套餐配置找不到），是否需要告警？（建议：Error 日志 + 现有 Asynq MaxRetry(3) 已覆盖）
