# 技术设计文档: 套餐系统升级

## Context

### 背景

当前套餐系统仅支持简单的按月计算模式，所有套餐立即生效，流量管理粗糙。新需求引入了代理商囤货场景（后台为未实名设备预购套餐，等待首次实名时自动激活）、灵活的套餐类型（自然月套餐、按天套餐）、多套餐管理（主套餐排队、加油包生命周期）、精细化流量统计（客户视图、套餐维度详单）等复杂业务逻辑。

### 当前状态

**已有实现**:
- 套餐模型 (`Package`, `PackageUsage`)：支持基础流量限额和使用统计
- 轮询系统 (`Scheduler`)：支持千万级卡规模的实名检查、流量检查、套餐检查
- 订单服务：支持套餐购买并立即激活（`activatePackage`）

**现有限制**:
- `Package.DurationMonths` 只支持按月计算，无法区分自然月和按天
- `PackageUsage.Status` 只有 1-生效中、2-已用完、3-已过期，无"待生效"和"已失效"状态
- 无流量重置周期概念（联通27号重置、日重置/月重置需求无法支持）
- 无主套餐排队机制，同时可存在多个生效中的主套餐
- 无加油包生命周期管理，加油包与主套餐无关联关系
- 流量扣减无优先级，停机条件只检查单一套餐
- 流量统计只有卡维度详单，无套餐维度详单和客户视图

### 约束条件

- 必须遵循 Handler → Service → Store → Model 分层架构
- 禁止外键约束和 GORM 关联关系（foreignKey, hasMany, belongsTo）
- 所有常量定义在 `pkg/constants/`，禁止硬编码
- 性能要求：套餐激活延迟 < 1分钟、API P95 < 200ms、千万级卡规模支持不退化
- 异步任务使用 Asynq，必须支持重试和幂等性

### 涉众

- **代理商**：需要囤货功能（提前为未实名设备低价采购套餐）
- **客户（企业/个人）**：需要区分主套餐和加油包用量、查看流量详单
- **运营团队**：需要精细化流量统计、套餐生命周期管理
- **开发团队**：负责实施和维护升级后的套餐系统

---

## Goals / Non-Goals

### Goals

1. **支持灵活的套餐类型**：自然月套餐（按月边界）、按天套餐（精确天数）
2. **支持流量重置周期**：日重置、月重置（联通27号/其他1号）、年重置、不重置
3. **支持首次实名激活**：代理商囤货场景，后台可为未实名设备购买套餐，等待实名时触发激活
4. **支持主套餐排队**：同时只能有一个生效中主套餐，后续购买自动排队，当前过期后自动激活下一个
5. **支持加油包生命周期**：加油包依附于主套餐，主套餐过期时级联失效
6. **支持流量扣减优先级**：优先扣减加油包（按 priority），再扣主套餐；全部用完才停机
7. **支持套餐流量详单**：按套餐维度记录每日流量增量，支持按日期查询
8. **支持客户视图流量查询**：区分主套餐和加油包用量，显示总计流量

### Non-Goals

1. **不支持加油包继承**：主套餐过期时，加油包统一失效（status=4），不转移到下一个主套餐
2. **不支持加油包跨主套餐共享**：加油包只为当前主套餐服务
3. **不支持套餐暂停/恢复**：套餐生命周期是单向的（待生效 → 生效中 → 已用完/已过期/已失效）
4. **不支持套餐流量转移**：套餐间流量不可转移或合并
5. **不修改现有卡流量详单逻辑**：卡维度详单 (`DataUsageRecord`) 保持不变
6. **不修改订单服务的分佣逻辑**：分佣计算逻辑不在本次改造范围内

---

## Decisions

### 1. 数据库 Schema 设计

#### 1.1 Package 表扩展

**方案**: 在 `tb_package` 表新增 3 个字段

```go
type Package struct {
    // ... 现有字段 ...

    // 新增字段
    CalendarType            string `gorm:"column:calendar_type;type:varchar(20);not null;default:'by_day';comment:周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
    DataResetCycle          string `gorm:"column:data_reset_cycle;type:varchar(20);not null;default:'none';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
    EnableRealnameActivation bool  `gorm:"column:enable_realname_activation;type:boolean;default:false;comment:是否需要首次实名激活（后台囤货场景）" json:"enable_realname_activation"`
}
```

**决策理由**:
- `calendar_type` 和 `data_reset_cycle` 是两个独立维度（套餐类型 vs 流量重置周期）
- `calendar_type=natural_month` 时必须提供 `duration_months`，`by_day` 时可提供 `duration_days`（如缺失则从 `duration_months` 转换）
- `enable_realname_activation=true` 的套餐，后台购买时创建 `PackageUsage(status=0, pending_realname_activation=true)`

**替代方案**:
- ~~使用 `JSONB` 字段存储扩展配置~~：不利于 SQL 查询和索引，违背"优先使用结构化字段"原则

#### 1.2 PackageUsage 表扩展

**方案**: 扩展 `tb_package_usage` 表状态和新增 7 个字段

```go
type PackageUsage struct {
    // ... 现有字段 ...

    // status 扩展：0-待生效, 1-生效中, 2-已用完, 3-已过期, 4-已失效
    Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"`

    // 新增字段
    Priority                  int       `gorm:"column:priority;type:int;index;comment:优先级（主套餐排队顺序，数字越小优先级越高）" json:"priority"`
    MasterUsageID             *uint     `gorm:"column:master_usage_id;index;comment:主套餐ID（加油包关联主套餐）" json:"master_usage_id"`
    HasIndependentExpiry      bool      `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期" json:"has_independent_expiry"`
    PendingRealnameActivation bool      `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待首次实名激活" json:"pending_realname_activation"`
    DataResetCycle            string    `gorm:"column:data_reset_cycle;type:varchar(20);not null;default:'none';comment:流量重置周期（从 Package 复制）" json:"data_reset_cycle"`
    LastResetAt               *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
    NextResetAt               *time.Time `gorm:"column:next_reset_at;comment:下次流量重置时间" json:"next_reset_at"`
}
```

**决策理由**:
- **priority**: 主套餐排队顺序，数字越小优先级越高（1 > 2 > 3）
- **master_usage_id**: 加油包关联主套餐 ID，实现生命周期管理（主套餐过期时级联失效）
- **has_independent_expiry**: 加油包有效期模式（true=独立有效期，false=跟随主套餐）
- **pending_realname_activation**: 标识是否等待首次实名激活（后台囤货场景）
- **data_reset_cycle**: 从 `Package.DataResetCycle` 复制，避免 JOIN 查询
- **last_reset_at / next_reset_at**: 支持流量重置调度

**索引策略**:
- `priority` 索引：支持主套餐排队查询（`WHERE status=0 AND priority=MIN(priority)`）
- `master_usage_id` 索引：支持加油包级联失效查询（`WHERE master_usage_id=?`）

**替代方案**:
- ~~使用单独的 `PackageQueue` 表管理主套餐排队~~：增加复杂度，状态分散在两个表中，不利于一致性保证

#### 1.3 IoT卡表扩展

**方案**: 在 `tb_iot_card` 表新增幂等字段

```go
type IotCard struct {
    // ... 现有字段 ...

    // 新增字段
    FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间，NULL=未实名，非NULL=已实名（幂等标记）" json:"first_realname_at"`
}
```

**决策理由**：
- 比 `realname_status` 字段更可靠（状态可能被重置，时间戳不可逆）
- 可追溯首次实名时间
- 数据库层面保证唯一更新（`UPDATE SET first_realname_at=NOW() WHERE id=? AND first_realname_at IS NULL`）
- 支持幂等性：NULL=未实名，非NULL=已处理

**使用方式**：
```sql
-- 首次实名触发时
UPDATE tb_iot_card
SET first_realname_at = NOW()
WHERE id = ? AND first_realname_at IS NULL;

-- 判断是否首次实名
SELECT first_realname_at FROM tb_iot_card WHERE id = ?;
-- NULL = 首次，执行激活逻辑
-- 非NULL = 已处理，跳过
```

#### 1.4 运营商表扩展

**方案**: 在 `tb_carrier` 表新增计费日配置字段

```go
type Carrier struct {
    // ... 现有字段 ...

    // 新增字段
    BillingDay *int `gorm:"column:billing_day;comment:计费日（1-31），NULL=默认1号，27=联通" json:"billing_day"`
}
```

**决策理由**：
- 可配置化，无需硬编码联通27号规则
- 支持运营商策略变更
- 便于新运营商接入
- 便于测试（可模拟不同计费日）

**数据初始化**：
```sql
UPDATE tb_carrier SET billing_day = 27 WHERE name = '中国联通';
UPDATE tb_carrier SET billing_day = 1 WHERE name IN ('中国移动', '中国电信');
```

#### 1.5 新增卡日流量详单表

**方案**: 创建卡维度日流量统计表

```go
type CardDailyUsage struct {
    gorm.Model
    CardID          uint      `gorm:"column:card_id;index:idx_card_date;not null;comment:卡ID" json:"card_id"`
    UsageDate       time.Time `gorm:"column:usage_date;type:date;index:idx_card_date;not null;comment:使用日期" json:"usage_date"`
    TotalDataUsage  int64     `gorm:"column:total_data_usage;type:bigint;not null;comment:总流量使用（字节），聚合该卡当日所有套餐的用量" json:"total_data_usage"`
    CarrierID       int       `gorm:"column:carrier_id;comment:运营商ID（冗余，便于查询）" json:"carrier_id"`
}

// 唯一索引
CREATE UNIQUE INDEX idx_card_date ON tb_card_daily_usage(card_id, usage_date) WHERE deleted_at IS NULL;

// 日期索引（便于按日期范围查询）
CREATE INDEX idx_usage_date ON tb_card_daily_usage(usage_date);
```

**决策理由**：
- **支持卡维度流量统计查询**：不需要聚合多个套餐记录
- **简化账单生成**：直接查询卡日流量，无需JOIN套餐表
- **提升查询性能**：避免复杂的GROUP BY和SUM操作
- **流量告警触发**：快速查询卡当日总流量

**数据来源**：
```
卡日总流量 = SUM(该卡所有生效套餐当日增量)
```

**与 PackageUsageDailyRecord 的关系**：
- `PackageUsageDailyRecord`：套餐维度详单（区分主套餐和加油包）
- `CardDailyUsage`：卡维度汇总（所有套餐总和）
- 两者互补，不重复

#### 1.6 新增 PackageUsageDailyRecord 表

**方案**: 创建套餐流量日记录表

```go
type PackageUsageDailyRecord struct {
    gorm.Model
    BaseModel           `gorm:"embedded"`
    PackageUsageID      uint      `gorm:"column:package_usage_id;index:idx_usage_date;not null;comment:套餐使用记录ID" json:"package_usage_id"`
    Date                time.Time `gorm:"column:date;type:date;index:idx_usage_date;not null;comment:日期" json:"date"`
    DailyUsageMB        int64     `gorm:"column:daily_usage_mb;type:bigint;not null;comment:当日流量增量(MB)" json:"daily_usage_mb"`
    CumulativeUsageMB   int64     `gorm:"column:cumulative_usage_mb;type:bigint;not null;comment:累计流量(MB)" json:"cumulative_usage_mb"`
}

// 唯一索引
CREATE UNIQUE INDEX idx_package_usage_date ON tb_package_usage_daily_record(package_usage_id, date) WHERE deleted_at IS NULL;
```

**决策理由**:
- **按套餐维度记录**：每个 `PackageUsage` 每天一条记录
- **daily_usage_mb**: 当天流量增量（基于上游累计流量计算）
- **cumulative_usage_mb**: 截至当天的累计流量
- **联合唯一索引**：确保同一套餐同一天只有一条记录

**数据来源**:
```
今日增量 = max(上游返回累计流量 - 昨日 cumulative_usage_mb, 0)
```

**替代方案**:
- ~~复用现有 `DataUsageRecord` 表~~：`DataUsageRecord` 按卡维度记录，无法区分主套餐和加油包，需要独立表

---

### 2. 业务流程设计

#### 2.1 首次实名激活流程

```
┌─────────────────────────────────────────────────────────────────┐
│  后台订单服务（Order Service）                                  │
├─────────────────────────────────────────────────────────────────┤
│  1. 代理商为未实名设备购买套餐（enable_realname_activation=true）│
│  2. 创建 PackageUsage(status=0, pending_realname_activation=true)│
│     activated_at=NULL, expires_at=NULL                          │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 实名检查任务（Realname Handler）                    │
├─────────────────────────────────────────────────────────────────┤
│  3. 检测到首次实名（realname_status: 0/1 → 2）                  │
│  4. 查询该卡/设备是否有待激活套餐                               │
│     WHERE pending_realname_activation=true AND status=0         │
│  5. 提交 Asynq 任务: TaskTypePackageFirstActivation            │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  Asynq Handler - 套餐首次实名激活任务                           │
├─────────────────────────────────────────────────────────────────┤
│  6. 根据 calendar_type 计算 activated_at 和 expires_at          │
│     - natural_month: 激活时间=当前时间，过期时间=月末23:59:59  │
│     - by_day: 激活时间=当前时间，过期时间=激活时间+N天         │
│  7. 更新 PackageUsage                                           │
│     status=1, pending_realname_activation=false,                │
│     activated_at=计算值, expires_at=计算值                       │
│  8. 记录操作日志                                                 │
└─────────────────────────────────────────────────────────────────┘
```

**幂等性保证**:
- 任务处理前检查 `pending_realname_activation=false`，已激活则直接返回成功
- 使用数据库事务更新 `PackageUsage`

**重试策略**:
- 最大重试 3 次（Asynq `MaxRetry(3)`）
- 超时时间 30 秒（Asynq `Timeout(30s)`）

**性能目标**:
- 实名检测到激活延迟 < 30 秒（取决于轮询间隔 + 队列延迟）

---

#### 2.2 主套餐排队生效流程

```
┌─────────────────────────────────────────────────────────────────┐
│  订单服务（Order Service）                                       │
├─────────────────────────────────────────────────────────────────┤
│  1. 用户购买主套餐（package_type=formal）                        │
│  2. 查询该载体当前生效中主套餐                                   │
│     WHERE usage_type=? AND (iot_card_id/device_id)=? AND        │
│           status=1 AND master_usage_id IS NULL                  │
│  3. 如果有生效中主套餐:                                           │
│     - 新套餐 status=0, priority=MAX(priority)+1                 │
│     - activated_at=NULL, expires_at=NULL                        │
│     如果无生效中主套餐:                                           │
│     - 新套餐 status=1, priority=1, 立即激活                      │
│     - 根据 calendar_type 计算 activated_at 和 expires_at        │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 套餐激活检查任务（每 10 秒调度一次）                 │
├─────────────────────────────────────────────────────────────────┤
│  4. 查询已过期主套餐（status=1 AND expires_at <= NOW）         │
│  5. 更新过期主套餐 status=3                                      │
│  6. 查询该载体下一个待生效主套餐                                 │
│     WHERE usage_type=? AND (iot_card_id/device_id)=? AND        │
│           status=0 AND master_usage_id IS NULL                  │
│     ORDER BY priority ASC LIMIT 1                               │
│  7. 提交 Asynq 任务: TaskTypePackageQueueActivation            │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  Asynq Handler - 主套餐排队激活任务                             │
├─────────────────────────────────────────────────────────────────┤
│  8. 根据 calendar_type 计算 activated_at 和 expires_at          │
│  9. 更新 PackageUsage status=1, activated_at, expires_at       │
│  10. 记录操作日志                                                │
└─────────────────────────────────────────────────────────────────┘
```

**激活延迟保证**:
- 目标: 主套餐过期后 1 分钟内激活下一个
- 调度间隔: 10 秒（`Scheduler.scheduleLoop` 每 10 秒执行一次）
- 性能分析:
  - 过期检查: 10 秒（最差情况，刚好错过本次调度）
  - 队列延迟: < 1 秒（Asynq 队列延迟）
  - 激活处理: < 5 秒（数据库更新 + 日志记录）
  - **总延迟 < 20 秒**（满足 < 1 分钟要求）

**幂等性保证**:
- 任务处理前检查 `status=1`，已激活则直接返回成功
- 使用数据库事务 + 乐观锁（`WHERE status=0`）防止重复激活

---

#### 2.3 加油包生命周期管理

```
┌─────────────────────────────────────────────────────────────────┐
│  订单服务（Order Service）                                       │
├─────────────────────────────────────────────────────────────────┤
│  1. 用户购买加油包（package_type=addon）                         │
│  2. 检查该载体是否有生效中或待生效主套餐                         │
│     WHERE usage_type=? AND (iot_card_id/device_id)=? AND        │
│           status IN (0,1) AND master_usage_id IS NULL           │
│  3. 如果无主套餐: 返回错误 "必须有主套餐才能购买加油包"          │
│  4. 创建 PackageUsage:                                           │
│     - master_usage_id=主套餐ID                                   │
│     - status=1, priority=MAX(priority)+1 (同一主套餐下)         │
│     - has_independent_expiry=套餐配置的独立有效期模式            │
│     - 根据 has_independent_expiry 计算 expires_at               │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 套餐激活检查任务                                     │
├─────────────────────────────────────────────────────────────────┤
│  5. 查询已过期主套餐（status=1 AND expires_at <= NOW）         │
│  6. 更新主套餐 status=3                                          │
│  7. 查询该主套餐下的所有加油包                                   │
│     WHERE master_usage_id=主套餐ID                              │
│  8. 批量更新加油包 status=4（已失效）                           │
│  9. 记录操作日志                                                 │
└─────────────────────────────────────────────────────────────────┘
```

**有效期计算**:
- `has_independent_expiry=true`: 根据加油包套餐的 `calendar_type` 和 `duration_*` 计算
- `has_independent_expiry=false`: `expires_at=主套餐.expires_at`

**级联失效**:
- 主套餐过期时（`status=3`），批量更新关联加油包 `status=4`
- 不支持加油包转移到下一个主套餐

---

#### 2.4 流量扣减优先级流程

```
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 流量检查任务（Carddata Handler）                    │
├─────────────────────────────────────────────────────────────────┤
│  1. 查询上游流量（ICCID → 累计流量）                            │
│  2. 查询该卡当前生效套餐（status=1 的主套餐和加油包）           │
│  3. 按优先级排序：加油包（按 priority ASC）→ 主套餐             │
│  4. 依次扣减流量:                                                │
│     FOR EACH 套餐 IN 优先级列表:                                 │
│       剩余额度 = data_limit_mb - data_usage_mb                  │
│       IF 剩余额度 > 0:                                           │
│         扣减量 = MIN(本次增量, 剩余额度)                         │
│         UPDATE data_usage_mb += 扣减量                          │
│         本次增量 -= 扣减量                                       │
│         记录到 PackageUsageDailyRecord                          │
│       IF data_usage_mb >= data_limit_mb:                        │
│         UPDATE status=2 (已用完)                                │
│       IF 本次增量 == 0:                                          │
│         BREAK                                                   │
│  5. 检查停机条件:                                                │
│     IF 所有套餐 data_usage_mb >= data_limit_mb:                 │
│       触发停机操作                                               │
└─────────────────────────────────────────────────────────────────┘
```

**停机条件**:
- 旧逻辑: 单一套餐流量用完即停机
- 新逻辑: 主套餐 + 所有加油包流量全部用完才停机

**性能优化**:
- 批量查询套餐（一次 SQL 获取主套餐和所有加油包）
- 批量更新套餐（使用事务提交）

---

#### 2.5 流量重置调度流程

```
┌─────────────────────────────────────────────────────────────────┐
│  轮询系统 - 流量重置调度任务（每 10 秒调度一次）                 │
├─────────────────────────────────────────────────────────────────┤
│  1. 每日 0 点触发日重置:                                         │
│     WHERE data_reset_cycle='daily' AND next_reset_at <= NOW    │
│     UPDATE data_usage_mb=0, last_reset_at=NOW,                  │
│            next_reset_at=明天 00:00:00                          │
│                                                                 │
│  2. 每月 1 号（非联通）或 27 号（联通）触发月重置:               │
│     WHERE data_reset_cycle='monthly' AND next_reset_at <= NOW  │
│     UPDATE data_usage_mb=0, last_reset_at=NOW,                  │
│            next_reset_at=下月 1号/27号 00:00:00                │
│                                                                 │
│  3. 每年 1 月 1 日触发年重置:                                    │
│     WHERE data_reset_cycle='yearly' AND next_reset_at <= NOW   │
│     UPDATE data_usage_mb=0, last_reset_at=NOW,                  │
│            next_reset_at=明年 1月 1日 00:00:00                  │
└─────────────────────────────────────────────────────────────────┘
```

**重置时间计算**:
- `daily`: 每天 00:00:00
- `monthly`:
  - 联通卡（`carrier_id=CUCC`）: 每月 27 号 00:00:00
  - 其他运营商: 每月 1 号 00:00:00
- `yearly`: 每年 1 月 1 日 00:00:00
- `none`: 不重置（`next_reset_at=NULL`）

**分批处理**:
- 每次最多处理 10000 条记录（避免长事务）
- 使用游标分批查询（`WHERE id > last_id`）

**幂等性保证**:
- 使用 `next_reset_at <= NOW` 条件查询，已重置的记录 `next_reset_at` 已更新到未来时间

---

### 3. 状态机设计

#### 3.1 PackageUsage 状态转换图

```
                     ┌────────────────┐
                     │   0-待生效     │
                     │  (Pending)     │
                     └────────────────┘
                            │
              ┌─────────────┼─────────────┐
              │                           │
    首次实名激活 / 主套餐排队激活       过期前删除订单
              │                           │
              ↓                           ↓
      ┌────────────────┐           ┌────────────────┐
      │   1-生效中     │           │   已删除       │
      │  (Active)      │           │  (Deleted)     │
      └────────────────┘           └────────────────┘
              │
      ┌───────┴───────┐
      │               │
  流量用完       有效期过期
      │               │
      ↓               ↓
┌────────────────┐ ┌────────────────┐
│   2-已用完     │ │   3-已过期     │
│  (Depleted)    │ │  (Expired)     │
└────────────────┘ └────────────────┘
      │               │
      └───────┬───────┘
              │
      (仅加油包) 主套餐过期
              │
              ↓
      ┌────────────────┐
      │   4-已失效     │
      │  (Invalidated) │
      └────────────────┘
```

**状态说明**:
- **0-待生效**: 套餐已购买但未激活（等待首次实名或主套餐排队）
- **1-生效中**: 套餐正在生效，流量可用
- **2-已用完**: 流量已耗尽但未过期（可续费加油包）
- **3-已过期**: 有效期已过（主套餐过期触发下一个激活）
- **4-已失效**: 加油包跟随主套餐失效（仅加油包）

**不可逆性**: 状态转换是单向的，不支持反向转换（如已过期 → 生效中）

---

### 4. API 设计

#### 4.1 套餐管理 API 改造

**创建套餐 API**

```http
POST /api/admin/packages
Content-Type: application/json

{
  "package_code": "PACKAGE-001",
  "package_name": "联通月卡30GB",
  "series_id": 1,
  "package_type": "formal",

  // 新增字段
  "calendar_type": "natural_month",     // 必填: natural_month | by_day
  "duration_months": 1,                 // calendar_type=natural_month 时必填
  "duration_days": null,                // calendar_type=by_day 时必填
  "data_reset_cycle": "monthly",        // 必填: daily | monthly | yearly | none
  "enable_realname_activation": true,   // 可选，默认 false

  "real_data_mb": 30720,
  "virtual_data_mb": 0,
  "enable_virtual_data": false,
  "cost_price": 1500,
  "suggested_retail_price": 3000
}
```

**响应**:
```json
{
  "code": 200,
  "msg": "success",
  "data": {
    "id": 123,
    "package_code": "PACKAGE-001",
    "calendar_type": "natural_month",
    "data_reset_cycle": "monthly",
    "enable_realname_activation": true,
    // ... 其他字段
  }
}
```

**更新套餐 API**:
- 支持更新 `calendar_type`、`data_reset_cycle`、`enable_realname_activation`
- `package_code` 不可修改

---

#### 4.2 客户视图流量查询 API（新增）

```http
GET /api/h5/packages/my-usage
Authorization: Bearer <token>
```

**响应**:
```json
{
  "code": 200,
  "msg": "success",
  "data": {
    "main_package": {
      "package_usage_id": 1,
      "package_name": "联通月卡30GB",
      "used_mb": 8192,
      "total_mb": 30720,
      "status": 1,
      "expires_at": "2026-02-28T23:59:59Z"
    },
    "addon_packages": [
      {
        "package_usage_id": 2,
        "package_name": "流量加油包10GB",
        "used_mb": 3072,
        "total_mb": 10240,
        "status": 1,
        "expires_at": "2026-02-28T23:59:59Z"
      },
      {
        "package_usage_id": 3,
        "package_name": "流量加油包5GB",
        "used_mb": 1024,
        "total_mb": 5120,
        "status": 1,
        "expires_at": "2026-03-15T23:59:59Z"
      }
    ],
    "total": {
      "used_mb": 12288,
      "total_mb": 46080
    }
  }
}
```

**性能要求**: P95 < 200ms

**查询逻辑**:
1. 根据 token 获取 `user_id` 和载体信息（`iot_card_id` 或 `device_id`）
2. 查询生效中或已用完的套餐（`WHERE status IN (1,2)`）
3. 区分主套餐（`master_usage_id IS NULL`）和加油包（`master_usage_id IS NOT NULL`）
4. 计算总计流量（主套餐 + 所有加油包）

---

#### 4.3 套餐流量详单 API（新增）

```http
GET /api/admin/package-usage/:id/daily-records?start_date=2026-02-01&end_date=2026-02-10
Authorization: Bearer <token>
```

**响应**:
```json
{
  "code": 200,
  "msg": "success",
  "data": {
    "package_usage_id": 1,
    "package_name": "联通月卡30GB",
    "records": [
      {
        "date": "2026-02-01",
        "daily_usage_mb": 1024,
        "cumulative_usage_mb": 1024
      },
      {
        "date": "2026-02-02",
        "daily_usage_mb": 2048,
        "cumulative_usage_mb": 3072
      }
      // ...
    ],
    "total_usage_mb": 3072
  }
}
```

**查询逻辑**:
1. 验证越权（使用 `middleware.CanManageShop` 或 `middleware.CanManageEnterprise`）
2. 查询日记录表（`WHERE package_usage_id=? AND date BETWEEN ? AND ?`）
3. 按 `date ASC` 排序

---

### 5. 常量管理

在 `pkg/constants/constants.go` 新增以下常量:

```go
// 套餐周期类型
const (
    PackageCalendarTypeNaturalMonth = "natural_month" // 自然月
    PackageCalendarTypeByDay        = "by_day"        // 按天
)

// 套餐流量重置周期
const (
    PackageDataResetDaily   = "daily"   // 每日
    PackageDataResetMonthly = "monthly" // 每月
    PackageDataResetYearly  = "yearly"  // 每年
    PackageDataResetNone    = "none"    // 不重置
)

// 套餐使用状态
const (
    PackageUsageStatusPending     = 0 // 待生效
    PackageUsageStatusActive      = 1 // 生效中
    PackageUsageStatusDepleted    = 2 // 已用完
    PackageUsageStatusExpired     = 3 // 已过期
    PackageUsageStatusInvalidated = 4 // 已失效（加油包跟随主套餐）
)

// 任务类型
const (
    TaskTypePackageFirstActivation = "package:first_activation" // 首次实名激活
    TaskTypePackageQueueActivation = "package:queue_activation" // 主套餐排队激活
    TaskTypePackageDataReset       = "package:data_reset"       // 流量重置
)

// Redis 键函数
func RedisPackageActivationLockKey(usageID uint) string {
    return fmt.Sprintf("package:activation:lock:%d", usageID)
}
```

---

### 6. 依赖注入设计

#### 6.1 Store 层

```go
// internal/store/postgres/package.go
type PackageStore struct {
    db    *gorm.DB
    redis *redis.Client
}

func NewPackageStore(db *gorm.DB, redis *redis.Client) *PackageStore {
    return &PackageStore{db: db, redis: redis}
}

// internal/store/postgres/package_usage.go
type PackageUsageStore struct {
    db    *gorm.DB
    redis *redis.Client
}

func NewPackageUsageStore(db *gorm.DB, redis *redis.Client) *PackageUsageStore {
    return &PackageUsageStore{db: db, redis: redis}
}

// internal/store/postgres/package_usage_daily_record.go
type PackageUsageDailyRecordStore struct {
    db *gorm.DB
}

func NewPackageUsageDailyRecordStore(db *gorm.DB) *PackageUsageDailyRecordStore {
    return &PackageUsageDailyRecordStore{db: db}
}
```

#### 6.2 Service 层

```go
// internal/service/package/service.go
type Service struct {
    packageStore         *postgres.PackageStore
    packageUsageStore    *postgres.PackageUsageStore
    packageSeriesStore   *postgres.PackageSeriesStore
    logger               *zap.Logger
}

func NewService(
    packageStore *postgres.PackageStore,
    packageUsageStore *postgres.PackageUsageStore,
    packageSeriesStore *postgres.PackageSeriesStore,
    logger *zap.Logger,
) *Service {
    return &Service{
        packageStore:       packageStore,
        packageUsageStore:  packageUsageStore,
        packageSeriesStore: packageSeriesStore,
        logger:             logger,
    }
}

// internal/service/order/service.go
type Service struct {
    // ... 现有依赖 ...
    packageUsageStore *postgres.PackageUsageStore
    queueClient       *asynq.Client
}
```

#### 6.3 Handler 层

```go
// internal/handler/admin/package.go
type PackageHandler struct {
    packageService *package_service.Service
    logger         *zap.Logger
}

func NewPackageHandler(
    packageService *package_service.Service,
    logger *zap.Logger,
) *PackageHandler {
    return &PackageHandler{
        packageService: packageService,
        logger:         logger,
    }
}

// internal/handler/h5/package_usage.go（新增）
type PackageUsageHandler struct {
    packageUsageService *package_service.Service
    logger              *zap.Logger
}

func NewPackageUsageHandler(
    packageUsageService *package_service.Service,
    logger *zap.Logger,
) *PackageUsageHandler {
    return &PackageUsageHandler{
        packageUsageService: packageUsageService,
        logger:              logger,
    }
}
```

---

### 7. 事务处理设计

#### 7.1 主套餐排队激活事务

```go
func (s *Service) ActivateQueuedPackage(ctx context.Context, usageID uint) error {
    // 使用 Redis 分布式锁避免并发激活
    lockKey := constants.RedisPackageActivationLockKey(usageID)
    lock := s.redis.SetNX(ctx, lockKey, 1, 30*time.Second)
    if !lock.Val() {
        return errors.New(errors.CodeConflict, "套餐正在激活中，请稍后重试")
    }
    defer s.redis.Del(ctx, lockKey)

    // 开启事务
    return s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 查询待激活套餐（加行锁）
        var usage model.PackageUsage
        err := tx.Where("id = ? AND status = ?", usageID, 0).
            Clauses(clause.Locking{Strength: "UPDATE"}).
            First(&usage).Error
        if err != nil {
            return errors.Wrap(errors.CodeNotFound, err, "套餐不存在或已激活")
        }

        // 2. 查询套餐配置
        var pkg model.Package
        if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
            return errors.Wrap(errors.CodeInternal, err, "查询套餐配置失败")
        }

        // 3. 计算激活时间和过期时间
        activatedAt := time.Now()
        expiresAt := s.calculateExpiryTime(activatedAt, pkg.CalendarType, pkg.DurationMonths, pkg.DurationDays)

        // 4. 更新 PackageUsage
        err = tx.Model(&usage).Updates(map[string]interface{}{
            "status":       1,
            "activated_at": activatedAt,
            "expires_at":   expiresAt,
        }).Error
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "更新套餐状态失败")
        }

        // 5. 记录操作日志
        s.logger.Info("主套餐排队激活成功",
            zap.Uint("usage_id", usageID),
            zap.Time("activated_at", activatedAt),
            zap.Time("expires_at", expiresAt))

        return nil
    })
}
```

#### 7.2 加油包级联失效事务

```go
func (s *Service) CascadeInvalidateAddons(ctx context.Context, masterUsageID uint) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        // 1. 查询主套餐下的所有加油包
        var addons []*model.PackageUsage
        err := tx.Where("master_usage_id = ? AND status IN (1,2)", masterUsageID).
            Find(&addons).Error
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "查询加油包失败")
        }

        if len(addons) == 0 {
            return nil // 无加油包，直接返回
        }

        // 2. 批量更新加油包状态为"已失效"
        addonIDs := make([]uint, len(addons))
        for i, addon := range addons {
            addonIDs[i] = addon.ID
        }

        err = tx.Model(&model.PackageUsage{}).
            Where("id IN ?", addonIDs).
            Update("status", 4).Error
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "更新加油包状态失败")
        }

        // 3. 记录操作日志
        s.logger.Info("加油包级联失效完成",
            zap.Uint("master_usage_id", masterUsageID),
            zap.Int("invalidated_count", len(addons)))

        return nil
    })
}
```

---

## 现有代码修正清单

本章节列出套餐系统升级中需要修改的现有代码缺陷和不兼容逻辑。

### 1. 数据重置日期计算逻辑修正

**文件位置**: `internal/service/iot_card/traffic_utils.go` 或类似文件（需确认实际位置）

**问题描述**:
- 当前代码硬编码按自然月重置（每月1号）
- 不支持 `by_day` 类型（购买日周期）
- 不支持联通卡特殊规则（27号重置）

**修改方案**:
```go
// 旧代码（需删除或重构）
func calculateResetDate(activatedAt time.Time) time.Time {
    return time.Date(activatedAt.Year(), activatedAt.Month()+1, 1, 0, 0, 0, 0, time.UTC)
}

// 新代码（支持多种重置周期）
func calculateResetDate(pkg *model.Package, activatedAt time.Time, carrierID int) time.Time {
    switch pkg.DataResetCycle {
    case constants.PackageDataResetDaily:
        // 每日：明天 00:00:00
        return time.Date(activatedAt.Year(), activatedAt.Month(), activatedAt.Day()+1, 0, 0, 0, 0, time.UTC)

    case constants.PackageDataResetMonthly:
        // 每月：根据运营商确定计费日
        billingDay := 1
        if carrierID == constants.CarrierCUCC { // 联通
            billingDay = 27
        }
        // 计算下个计费日
        year, month := activatedAt.Year(), activatedAt.Month()+1
        if month > 12 {
            year++
            month = 1
        }
        // 处理月末边界（如31号在2月不存在）
        lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
        if billingDay > lastDayOfMonth {
            billingDay = lastDayOfMonth
        }
        return time.Date(year, month, billingDay, 0, 0, 0, 0, time.UTC)

    case constants.PackageDataResetYearly:
        // 每年：明年 1月1日 00:00:00
        return time.Date(activatedAt.Year()+1, 1, 1, 0, 0, 0, 0, time.UTC)

    case constants.PackageDataResetNone:
        // 不重置
        return time.Time{}

    default:
        // 默认按月
        return time.Date(activatedAt.Year(), activatedAt.Month()+1, 1, 0, 0, 0, 0, time.UTC)
    }
}
```

**影响范围**:
- 订单服务创建套餐时计算 `next_reset_at`
- 轮询系统流量重置调度
- 单元测试需新增联通卡、按天套餐测试用例

---

### 2. 流量扣减逻辑重构

**文件位置**: `internal/handler/worker/iot_card_traffic.go` 或 `internal/service/iot_card/traffic_deduction.go`

**问题描述**:
- 当前按套餐激活顺序扣减
- 不支持"加油包优先"规则
- 未区分主套餐和加油包

**修改方案**:
```go
// 旧代码（需删除）
func (s *Service) DeductTraffic(ctx context.Context, cardID uint, increment int64) error {
    // 查询生效套餐，按 activated_at ASC 排序
    packages := s.store.GetActivePackages(cardID) // 问题：不区分主套餐和加油包

    for _, pkg := range packages {
        // 按激活时间顺序扣减（错误逻辑）
        ...
    }
}

// 新代码（支持优先级扣减）
func (s *Service) DeductTraffic(ctx context.Context, cardID uint, increment int64) error {
    // 1. 查询卡的所有生效套餐
    packages, err := s.store.GetActivePackagesByPriority(ctx, cardID)
    if err != nil {
        return errors.Wrap(errors.CodeInternal, err, "查询生效套餐失败")
    }

    // 2. 按优先级排序（加油包优先，再按 priority ASC, expires_at ASC, activated_at ASC）
    // Store 层已排序，此处直接使用

    // 3. 依次扣减
    remainingIncrement := increment
    for _, pkg := range packages {
        if remainingIncrement <= 0 {
            break
        }

        availableData := pkg.DataLimitMB - pkg.DataUsageMB
        if availableData <= 0 {
            continue // 已用完，跳过
        }

        deductAmount := min(remainingIncrement, availableData)

        // 更新套餐用量
        err := s.store.UpdateDataUsage(ctx, pkg.ID, deductAmount)
        if err != nil {
            return errors.Wrap(errors.CodeInternal, err, "更新套餐用量失败")
        }

        // 记录日记录
        err = s.recordDailyUsage(ctx, pkg.ID, deductAmount)
        if err != nil {
            // 日记录失败不影响扣减（仅记录日志）
            s.logger.Error("记录日用量失败", zap.Error(err))
        }

        remainingIncrement -= deductAmount

        // 检查套餐是否用完
        if pkg.DataUsageMB+deductAmount >= pkg.DataLimitMB {
            err := s.store.UpdatePackageStatus(ctx, pkg.ID, constants.PackageUsageStatusDepleted)
            if err != nil {
                s.logger.Error("更新套餐状态失败", zap.Error(err))
            }
        }
    }

    // 4. 检查停机条件
    if remainingIncrement > 0 || s.shouldStopCard(ctx, cardID) {
        return s.stopCard(ctx, cardID)
    }

    return nil
}

// Store 层查询方法（新增）
func (s *Store) GetActivePackagesByPriority(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
    var packages []*model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("iot_card_id = ? AND status = ?", cardID, constants.PackageUsageStatusActive).
        Order("(master_usage_id IS NOT NULL) DESC, priority ASC, expires_at ASC, activated_at ASC").
        Find(&packages).Error
    return packages, err
}
```

**影响范围**:
- 轮询系统流量检查任务
- Store 层新增 `GetActivePackagesByPriority` 方法
- 单元测试需覆盖多加油包扣减场景

---

### 3. 轮询系统套餐激活入口

**文件位置**: `internal/handler/worker/iot_card_polling.go`

**问题描述**:
- 当前轮询仅处理卡状态同步
- 不处理待激活套餐队列
- 不处理主套餐过期检测

**修改方案**:
```go
// 旧代码
func (h *Handler) HandleIotCardPolling(ctx context.Context, task *asynq.Task) error {
    // 解析任务参数
    var payload IotCardPollingPayload
    if err := json.Unmarshal(task.Payload(), &payload); err != nil {
        return err
    }

    // 1. 同步卡状态
    err := h.syncCardStatus(ctx, payload.CardID)
    if err != nil {
        return err
    }

    // 2. 同步流量
    err = h.syncCardTraffic(ctx, payload.CardID)
    if err != nil {
        return err
    }

    // 3. 同步费用
    err = h.syncCardBalance(ctx, payload.CardID)
    if err != nil {
        return err
    }

    return nil // 缺少套餐激活检查
}

// 新代码（增加套餐激活检查）
func (h *Handler) HandleIotCardPolling(ctx context.Context, task *asynq.Task) error {
    // ... 现有逻辑：同步卡状态、流量、费用

    // 4. 新增：检查并激活排队套餐
    card, err := h.iotCardStore.GetByID(ctx, payload.CardID)
    if err != nil {
        return err
    }

    if card.Status == constants.IotCardStatusActive {
        // 检查是否有待激活套餐
        err := h.packageActivationService.CheckAndActivateQueuedPackages(ctx, card.ID)
        if err != nil {
            // 不中断轮询，记录日志继续
            h.logger.Error("激活排队套餐失败",
                zap.Uint("card_id", card.ID),
                zap.Error(err))
        }
    }

    // 5. 新增：检查主套餐是否过期
    err = h.packageActivationService.CheckExpiredPackages(ctx, card.ID)
    if err != nil {
        h.logger.Error("检查过期套餐失败",
            zap.Uint("card_id", card.ID),
            zap.Error(err))
    }

    return nil
}
```

**影响范围**:
- 轮询系统 Handler 层
- 新增 `PackageActivationService` 依赖注入
- 集成测试需验证完整轮询流程

---

### 4. 加油包过期级联处理

**位置**: 新增功能，无现有代码需修改

**说明**:
- 当前系统无加油包概念，这是全新功能
- 需在套餐过期处理流程中增加级联失效逻辑
- 详见 `addon-package-lifecycle/spec.md`

**新增代码位置**:
- `internal/service/package/lifecycle_service.go`（新建）
- `internal/handler/worker/package_expiry.go`（新建或扩展）

**核心逻辑**:
```go
func (s *Service) HandleMainPackageExpiry(ctx context.Context, mainPackageID uint) error {
    tx := s.db.BeginTx(ctx)
    defer tx.Rollback()

    // 1. 更新主套餐状态为已过期
    err := s.store.UpdatePackageStatus(ctx, tx, mainPackageID, constants.PackageUsageStatusExpired)
    if err != nil {
        return err
    }

    // 2. 查询关联的加油包
    addons, err := s.store.GetAddonsByMasterID(ctx, mainPackageID)
    if err != nil {
        return err
    }

    // 3. 批量级联失效加油包
    if len(addons) > 0 {
        addonIDs := extractIDs(addons)
        err = s.store.BatchUpdateStatus(ctx, tx, addonIDs, constants.PackageUsageStatusInvalidated)
        if err != nil {
            return err
        }
    }

    // 4. 提交事务
    if err := tx.Commit().Error; err != nil {
        return err
    }

    // 5. 记录审计日志
    s.auditService.LogOperation(ctx, &model.OperationLog{
        OperationType: "cascade_invalidate",
        OperationDesc: fmt.Sprintf("主套餐ID=%d过期，级联失效%d个加油包", mainPackageID, len(addons)),
    })

    return nil
}
```

---

### 5. 停机条件更新

**文件位置**: `internal/service/iot_card/stop_service.go` 或类似文件

**问题描述**:
- 旧逻辑：单一套餐流量用完即停机
- 新逻辑：主套餐 + 所有加油包流量全部用完才停机

**修改方案**:
```go
// 旧代码（需删除）
func (s *Service) CheckStopCondition(ctx context.Context, cardID uint) (bool, error) {
    // 查询生效中套餐
    pkg, err := s.store.GetActiveMainPackage(ctx, cardID)
    if err != nil {
        return false, err
    }

    // 旧逻辑：主套餐用完即停机（错误）
    if pkg.DataUsageMB >= pkg.DataLimitMB {
        return true, nil
    }

    return false, nil
}

// 新代码（检查所有套餐）
func (s *Service) CheckStopCondition(ctx context.Context, cardID uint) (bool, error) {
    // 查询所有生效中的套餐（包括主套餐和加油包）
    count, err := s.store.CountAvailablePackages(ctx, cardID)
    if err != nil {
        return false, err
    }

    // 新逻辑：所有套餐都用完才停机
    return count == 0, nil
}

// Store 层查询方法（新增）
func (s *Store) CountAvailablePackages(ctx context.Context, cardID uint) (int64, error) {
    var count int64
    err := s.db.WithContext(ctx).
        Model(&model.PackageUsage{}).
        Where("iot_card_id = ? AND status = ? AND data_usage_mb < data_limit_mb",
            cardID, constants.PackageUsageStatusActive).
        Count(&count).Error
    return count, err
}
```

**影响范围**:
- 轮询系统流量检查后的停机判断
- 单元测试需覆盖"加油包剩余流量不停机"场景

---

## Risks / Trade-offs

### 1. [性能] 套餐激活调度频率 vs 激活延迟

**Risk**:
- 调度间隔 10 秒，最差情况下主套餐过期后 20 秒内激活下一个
- 如果同时有大量套餐过期，可能出现队列堆积

**Mitigation**:
- 调度间隔可配置（默认 10 秒），支持运行时调整
- 套餐激活任务使用独立队列（优先级高于其他任务）
- 监控 Asynq 队列长度和处理延迟，告警阈值设置为 1 分钟

---

### 2. [复杂度] 流量扣减优先级逻辑的边界条件

**Risk**:
- 多个加油包 + 主套餐的流量扣减逻辑复杂，容易出现边界问题（如负数流量、扣减不完全）
- 并发场景下可能出现流量扣减不一致

**Mitigation**:
- 流量扣减使用数据库事务 + 行锁（`SELECT FOR UPDATE`）
- 单元测试覆盖所有边界条件：
  - 流量刚好用完
  - 流量超出剩余额度
  - 多个加油包同时用完
  - 主套餐和加油包同时用完
- 代码层面强制约束 `data_usage_mb >= 0`

---

### 3. [迁移] 历史数据兼容性

**Risk**:
- 现有 `PackageUsage` 数据没有 `priority`、`master_usage_id` 等新字段
- 现有套餐没有 `calendar_type`、`data_reset_cycle` 字段

**Mitigation**:
- 数据库迁移脚本为新字段设置默认值：
  - `Package.calendar_type` 默认 `by_day`
  - `Package.data_reset_cycle` 默认 `none`
  - `PackageUsage.priority` 默认 `1`（历史主套餐）
  - `PackageUsage.master_usage_id` 默认 `NULL`（历史主套餐）
- 迁移后运行数据校验脚本，确保历史数据一致性

---

### 4. [兼容性] 客户端未实名购买限制

**Risk**:
- 现有 H5 端允许未实名客户购买套餐，新限制可能导致用户体验下降

**Mitigation**:
- 前端在购买按钮前置提示"请先完成实名认证"
- API 返回清晰的错误消息："设备/卡必须先完成实名认证才能购买套餐"
- 后台管理端不受限制，代理商可为未实名设备囤货

---

### 5. [数据一致性] 流量重置可能丢失部分流量记录

**Risk**:
- 流量重置时 `data_usage_mb=0`，如果在重置前有流量增量未记录到日记录表，会导致数据丢失

**Mitigation**:
- 流量重置前先触发一次流量检查，确保最新流量已记录
- 流量重置和流量检查不在同一事务中，避免长事务
- 监控流量重置任务的执行日志，告警异常情况

---

## Migration Plan

### 阶段 1: 数据库迁移

1. **创建迁移脚本**:
   ```bash
   make create-migration name=package_system_upgrade
   ```

2. **迁移内容**:
   - 修改 `tb_package` 表（新增 3 个字段）
   - 修改 `tb_package_usage` 表（扩展 status 枚举，新增 7 个字段）
   - 创建 `tb_package_usage_daily_record` 表
   - 创建索引（priority, master_usage_id, package_usage_id+date）

3. **回滚策略**:
   - 删除新增表 `tb_package_usage_daily_record`
   - 删除新增字段（使用 `ALTER TABLE DROP COLUMN`）
   - 注意：状态枚举扩展无法回滚（已写入的 status=0/4 数据会保留）

---

### 阶段 2: 代码实施

1. **Model 层**:
   - 扩展 `Package` 和 `PackageUsage` 模型
   - 创建 `PackageUsageDailyRecord` 模型

2. **Store 层**:
   - 扩展 `PackageStore` 和 `PackageUsageStore` 查询方法
   - 创建 `PackageUsageDailyRecordStore`

3. **Service 层**:
   - 扩展 `package.Service` 支持新字段
   - 改造 `order.Service` 的 `activatePackage` 函数（主套餐排队、加油包限制）
   - 创建套餐激活 Service（首次实名激活、排队激活）

4. **Handler 层**:
   - 改造 `admin.PackageHandler` 支持新字段
   - 创建 `h5.PackageUsageHandler` 提供客户视图 API

5. **轮询系统**:
   - 扩展 `HandleCarddataCheck` 支持流量扣减优先级和停机条件
   - 创建 `HandlePackageActivation` 套餐激活检查任务
   - 创建 `HandleDataReset` 流量重置调度任务

6. **Asynq Handler**:
   - 创建 `HandlePackageFirstActivation` 首次实名激活任务
   - 创建 `HandlePackageQueueActivation` 主套餐排队激活任务

---

### 阶段 3: 测试和验证

1. **单元测试**:
   - 套餐有效期计算逻辑（自然月 vs 按天）
   - 流量扣减优先级逻辑（边界条件）
   - 流量重置时间计算（联通27号 vs 其他1号）

2. **集成测试**:
   - 首次实名激活流程（囤货 → 实名 → 激活）
   - 主套餐排队流程（购买 → 排队 → 过期 → 激活）
   - 加油包生命周期（购买 → 主套餐过期 → 加油包失效）
   - 流量扣减和停机（加油包优先 → 主套餐 → 停机）

3. **性能测试**:
   - 套餐激活延迟（目标 < 1 分钟）
   - 客户视图 API 性能（P95 < 200ms）
   - 轮询系统千万级卡规模支持不退化

---

### 阶段 4: 部署和回滚策略

1. **灰度发布**:
   - 先在测试环境部署，完整验证所有流程
   - 生产环境先部署代码（特性开关关闭）
   - 执行数据库迁移
   - 开启特性开关，观察日志和监控

2. **回滚策略**:
   - **代码回滚**: 关闭特性开关 → 回滚代码
   - **数据库回滚**: 执行回滚迁移脚本（注意状态枚举扩展无法回滚）
   - **数据修复**: 如有脏数据（status=0/4），手动修正或保留（不影响现有功能）

3. **监控指标**:
   - Asynq 任务队列长度和处理延迟
   - 套餐激活延迟（从过期到激活的时间）
   - API 响应时间（客户视图 API P95）
   - 流量扣减错误率（日志错误数）

---

## Open Questions

1. **加油包优先级分配策略**：是否需要支持手动调整加油包的扣减顺序？还是严格按购买顺序（priority）？
   - **当前决策**: 严格按 priority（购买顺序），不支持手动调整
   - **未来扩展**: 可考虑在 API 中支持更新 priority（需要验证业务必要性）

2. **流量重置时的日记录处理**：流量重置后，是否需要在日记录表中新增一条"重置记录"（daily_usage_mb=0）？
   - **当前决策**: 不新增重置记录，日记录只记录实际流量增量
   - **原因**: 避免日记录表膨胀，重置信息可从 `PackageUsage.last_reset_at` 获取

3. **客户端实名校验的异常场景**：如果用户在支付成功后、订单完成前完成实名，是否允许购买？
   - **当前决策**: 订单创建时检查实名状态，支付完成后不再检查
   - **原因**: 简化逻辑，避免状态不一致（支付成功但订单失败）

4. **套餐激活失败的重试策略**：如果 Asynq 任务重试 3 次后仍失败，套餐是否永久停留在"待生效"状态？
   - **当前决策**: 是，需要人工介入修复
   - **监控**: 告警通知 + 日志记录，运营团队定期检查

5. **历史套餐的 data_reset_cycle 默认值**：现有套餐迁移后 `data_reset_cycle=none`，如需调整为 `monthly`，是否需要批量更新？
   - **当前决策**: 不批量更新，仅对新套餐生效
   - **原因**: 避免影响历史套餐的流量统计（用户预期不重置）
