# Spec: 加油包生命周期管理

## 业务背景

### 为什么需要加油包生命周期管理

**现状问题**：
- 加油包与主套餐无明确关联，导致主套餐过期后加油包仍可使用（业务逻辑混乱）
- 加油包有效期管理不清晰，无法区分"独立有效期"和"跟随主套餐"两种模式
- 主套餐切换时，旧加油包是否继承到新主套餐无明确规则
- 用户购买加油包时无主套餐检查，可能导致加油包无法使用

**业务目标**：
- 加油包必须依附于主套餐才能购买和使用
- 主套餐过期时，其关联的加油包自动失效（级联失效）
- 支持两种有效期模式：独立有效期（固定时长）和跟随主套餐（与主套餐同时到期）
- 主套餐切换时，旧加油包不继承到新主套餐（用户需重新购买）

---

## 业务规则

### 1. 依附规则

加油包必须在有主套餐的情况下才能购买：

```
购买加油包前置检查：
1. 查询载体当前是否有主套餐（package_type=formal AND status IN (0待生效, 1生效中)）
2. 如果无主套餐 → 返回错误 400："必须有主套餐才能购买加油包"
3. 如果有主套餐 → 允许购买
```

### 2. 关联规则

加油包创建时自动关联到当前生效中的主套餐：

```
确定 master_usage_id 的逻辑：
1. 查询载体当前生效中的主套餐（package_type=formal AND status=1）
2. 如果有生效中主套餐 → master_usage_id = 该主套餐ID
3. 如果无生效中主套餐，但有待生效主套餐（status=0）→ master_usage_id = priority 最小的待生效主套餐ID
4. 创建 PackageUsage 记录：
   - package_type = addon
   - master_usage_id = 上述确定的主套餐ID
   - status = 0（待生效）
   - has_independent_expiry = 根据套餐配置
```

### 3. 有效期模式

加油包支持两种有效期模式：

| 模式 | has_independent_expiry | 计算规则 | 过期条件 |
|------|------------------------|----------|----------|
| **独立有效期** | true | `expires_at = activated_at + duration_days` | 自身到期时间到达 |
| **跟随主套餐** | false | `expires_at = master套餐.expires_at` | 主套餐到期时间到达 |

**独立有效期加油包**：
- 激活时计算自己的 `expires_at`
- 可能在主套餐之前过期
- 到期后 `status=3`（已过期）

**跟随主套餐加油包**：
- 激活时 `expires_at = master套餐.expires_at`
- 主套餐 `expires_at` 更新时，同步更新所有跟随的加油包
- 与主套餐同时到期

### 4. 级联失效规则

主套餐过期时，级联失效其所有关联的加油包：

```
主套餐过期触发级联失效：
1. 主套餐 status 变为 3（已过期）时触发
2. 查询所有 master_usage_id = 主套餐ID 的加油包
3. 批量更新这些加油包 status = 4（已失效）
4. 不管加油包是否有独立有效期、是否已用完
5. 记录级联失效日志
```

**失效状态说明**：
- `status=3`（已过期）：自身有效期到达
- `status=4`（已失效）：主套餐过期导致的级联失效

### 5. 不继承规则

旧主套餐过期后，其加油包不继承到新主套餐：

```
新主套餐激活时：
1. 不更新旧加油包的 master_usage_id
2. 旧加油包保持 status=4（已失效）
3. 用户需为新主套餐重新购买加油包
4. 新加油包 master_usage_id = 新主套餐ID
```

### 6. 订单购买限制

**同订单禁止混买正式套餐和加油包**：

```
订单创建校验规则：
1. 检查订单项中是否同时包含 package_type=formal 和 package_type=addon
2. 如果混买 → 返回错误 400："同订单不能同时购买正式套餐和加油包"
3. 原因：加油包依赖主套餐激活，订单处理时序无法保证主套餐先激活
4. 解决方案：前端购物车分类展示，提示用户分两单购买
```

**技术实现**：
```go
// 订单创建时校验
func (s *OrderService) ValidateOrderItems(items []*OrderItem) error {
    hasMainPackage := false
    hasAddonPackage := false

    for _, item := range items {
        pkg, err := s.packageStore.GetByID(item.PackageID)
        if err != nil {
            return err
        }

        if pkg.PackageType == constants.PackageTypeFormal {
            hasMainPackage = true
        } else if pkg.PackageType == constants.PackageTypeAddon {
            hasAddonPackage = true
        }
    }

    if hasMainPackage && hasAddonPackage {
        return errors.New(errors.CodeInvalidParam, "同订单不能同时购买正式套餐和加油包")
    }

    return nil
}
```

---

## ADDED Requirements

### Requirement: 加油包必须依附于主套餐

系统 SHALL 禁止在无主套餐（无 package_type=formal status=1 或 status=0 的套餐）时购买加油包。

#### Scenario: 无主套餐时购买加油包失败
- **GIVEN** 载体 ICCID=123456，无任何主套餐（无 package_type=formal status IN (0,1)）
- **WHEN** 用户尝试购买加油包（package_type=addon）
- **THEN** 系统返回错误 400，错误码 `ADDON_REQUIRES_MASTER`，错误消息："必须有主套餐才能购买加油包"

#### Scenario: 有主套餐时可购买加油包
- **GIVEN** 载体有生效中主套餐（ID=123, status=1）
- **WHEN** 用户购买加油包（package_id=456）
- **THEN** 系统创建订单成功，PackageUsage master_usage_id=123, package_type=addon, status=0

#### Scenario: 只有待生效主套餐时可购买加油包
- **GIVEN** 载体有待生效主套餐（ID=123, status=0, priority=1）
- **WHEN** 用户购买加油包
- **THEN** 系统创建订单成功，加油包 master_usage_id=123

### Requirement: 加油包关联主套餐

系统 SHALL 在创建加油包使用记录时，将其 master_usage_id 设置为当前生效中或最高优先级待生效的主套餐ID。

#### Scenario: 加油包关联当前生效中主套餐
- **GIVEN** 载体有生效中主套餐（ID=123, status=1）
- **WHEN** 用户购买加油包
- **THEN** 系统创建 PackageUsage：
  - master_usage_id=123
  - package_type=addon
  - status=0

#### Scenario: 多个主套餐时关联生效中的主套餐
- **GIVEN** 载体有：
  - 生效中主套餐（ID=123, status=1, priority=1）
  - 待生效主套餐（ID=124, status=0, priority=2）
  - 待生效主套餐（ID=125, status=0, priority=3）
- **WHEN** 用户购买加油包
- **THEN** 加油包 master_usage_id=123（优先关联生效中的主套餐）

#### Scenario: 只有待生效主套餐时关联优先级最高的
- **GIVEN** 载体有：
  - 待生效主套餐（ID=124, status=0, priority=1）
  - 待生效主套餐（ID=125, status=0, priority=2）
- **WHEN** 用户购买加油包
- **THEN** 加油包 master_usage_id=124（priority=1 最高）

### Requirement: 支持独立有效期加油包

系统 SHALL 支持加油包配置 has_independent_expiry=true，拥有独立的有效期。

#### Scenario: 独立有效期加油包激活时计算过期时间
- **GIVEN** 加油包 has_independent_expiry=true，duration_days=30
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
- **THEN** 系统计算 expires_at=2026-03-02 23:59:59（+30天）

#### Scenario: 独立有效期加油包过期
- **GIVEN** 加油包 has_independent_expiry=true，expires_at=2026-02-28 23:59:59，data_usage_mb=50（未用完）
- **WHEN** 系统时间到达 2026-03-01 00:00:00
- **THEN** 定时任务将加油包 status 更新为 3（已过期）

#### Scenario: 独立有效期加油包在主套餐有效期内过期
- **GIVEN** 主套餐有效期到 2026-12-31 23:59:59
- **AND** 加油包 has_independent_expiry=true，expires_at=2026-03-31 23:59:59
- **WHEN** 系统时间到达 2026-04-01 00:00:00
- **THEN** 加油包 status=3（已过期），主套餐仍为 status=1（生效中）

#### Scenario: 独立有效期加油包在主套餐过期后仍失效
- **GIVEN** 加油包 has_independent_expiry=true，expires_at=2026-12-31 23:59:59（未到期）
- **AND** 主套餐 expires_at=2026-11-30 23:59:59
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期（status=3）
- **THEN** 加油包被级联失效（status=4），不管自身 expires_at

### Requirement: 支持跟随主套餐的加油包

系统 SHALL 支持加油包配置 has_independent_expiry=false，跟随主套餐有效期。

#### Scenario: 跟随主套餐的加油包激活时同步到期时间
- **GIVEN** 加油包 has_independent_expiry=false，master 主套餐 expires_at=2026-12-31 23:59:59
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
- **THEN** 系统设置加油包 expires_at=2026-12-31 23:59:59（与主套餐相同）

#### Scenario: 主套餐更新有效期时同步加油包
- **GIVEN** 主套餐 ID=123，expires_at=2026-12-31 23:59:59
- **AND** 有3个加油包 master_usage_id=123，has_independent_expiry=false
- **WHEN** 主套餐 expires_at 被更新为 2027-01-31 23:59:59
- **THEN** 系统批量更新这3个加油包 expires_at=2027-01-31 23:59:59

#### Scenario: 主套餐有效期更新时不影响独立有效期加油包
- **GIVEN** 主套餐 ID=123，expires_at=2026-12-31 23:59:59
- **AND** 加油包A：has_independent_expiry=true，expires_at=2026-06-30 23:59:59
- **AND** 加油包B：has_independent_expiry=false，expires_at=2026-12-31 23:59:59
- **WHEN** 主套餐 expires_at 更新为 2027-01-31 23:59:59
- **THEN** 加油包A expires_at 保持 2026-06-30 23:59:59（不变）
- **AND** 加油包B expires_at 更新为 2027-01-31 23:59:59

#### Scenario: 跟随主套餐的加油包与主套餐同时过期
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
- **AND** 加油包 has_independent_expiry=false，expires_at=2026-12-31 23:59:59
- **WHEN** 系统时间到达 2027-01-01 00:00:00
- **THEN** 定时任务将主套餐和加油包 status 都更新为 3（已过期）

### Requirement: 主套餐过期时级联失效加油包

系统 SHALL 在主套餐过期（status 变为 3）时，将其所有关联加油包的 status 设置为 4（已失效）。

#### Scenario: 主套餐过期触发加油包失效
- **GIVEN** 主套餐 ID=123，expires_at=2026-12-31 23:59:59
- **AND** 有3个加油包 master_usage_id=123：
  - 加油包A：data_usage_mb=50（未用完）
  - 加油包B：data_usage_mb=200（已用完）
  - 加油包C：has_independent_expiry=true，expires_at=2027-06-30（未到期）
- **WHEN** 系统时间到达 2027-01-01 00:00:00，主套餐 status=3
- **THEN** 系统批量更新这3个加油包 status=4（已失效）

#### Scenario: 独立有效期加油包也会级联失效
- **GIVEN** 主套餐 expires_at=2026-11-30 23:59:59
- **AND** 加油包 has_independent_expiry=true，expires_at=2026-12-31 23:59:59（晚于主套餐）
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期
- **THEN** 加油包 status=4（已失效），不管自身还有30天才到期

#### Scenario: 已过期加油包不重复失效
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
- **AND** 加油包 has_independent_expiry=true，expires_at=2026-11-30 23:59:59，status=3（已过期）
- **WHEN** 主套餐在 2027-01-01 00:00:00 过期
- **THEN** 加油包 status 保持 3（已过期），不更新为 4

#### Scenario: 级联失效记录到审计日志
- **GIVEN** 主套餐 ID=123 过期，有5个关联加油包
- **WHEN** 系统执行级联失效
- **THEN** 系统记录审计日志：
  - operation_type=cascade_invalidate
  - operation_desc="主套餐ID=123过期，级联失效5个加油包"
  - before_data=加油包列表及原状态
  - after_data=加油包列表及新状态（status=4）

### Requirement: 加油包不继承到新主套餐

系统 SHALL 确保旧主套餐过期后，其加油包不会自动关联到新激活的主套餐。

#### Scenario: 新主套餐激活后加油包不关联
- **GIVEN** 主套餐A（ID=123）在 2026-12-31 过期，其加油包已失效（status=4）
- **WHEN** 主套餐B（ID=124）在 2027-01-01 激活（priority=2 → status=1）
- **THEN** 主套餐A的加油包 master_usage_id 保持 123，status 保持 4
- **AND** 主套餐B 无关联加油包

#### Scenario: 用户需为新主套餐重新购买加油包
- **GIVEN** 主套餐B（ID=124）刚激活（status=1）
- **WHEN** 用户购买新加油包
- **THEN** 新加油包 master_usage_id=124，status=0

#### Scenario: 旧加油包不可重新激活
- **GIVEN** 主套餐A的加油包（ID=999）已失效（status=4）
- **WHEN** 用户尝试手动激活这个加油包
- **THEN** 系统返回错误 400，错误码 `ADDON_MASTER_EXPIRED`，错误消息："关联的主套餐已过期，无法激活加油包"

---

## 边界条件

### 1. 主套餐失效但加油包未用完

- **场景**：主套餐过期时，加油包流量只用了10%
- **处理**：仍然级联失效（status=4），剩余流量不可用
- **业务规则**：加油包依附于主套餐，主套餐失效则加油包失效

### 2. 多个主套餐同时存在

- **场景**：有1个生效中主套餐 + 2个待生效主套餐
- **购买加油包时**：关联到生效中的主套餐
- **主套餐A过期后**：加油包随A失效，不继承到主套餐B

### 3. 并发购买加油包

- **场景**：两个请求同时为同一载体购买加油包
- **处理**：
  - 使用事务 + 行锁：`SELECT * FROM package_usage WHERE carrier_id=? AND package_type=formal AND status IN (0,1) ORDER BY status DESC, priority ASC FOR UPDATE`
  - 确保两个加油包关联到同一个主套餐

### 4. 主套餐有效期更新失败

- **场景**：主套餐 expires_at 更新时，同步跟随加油包失败
- **处理**：
  - 使用事务包裹主套餐更新和加油包批量更新
  - 更新失败则回滚，返回错误 500
  - 记录错误日志，包含主套餐ID和失败原因

### 5. 级联失效失败

- **场景**：主套餐过期时，批量更新加油包失败（数据库连接断开）
- **处理**：
  - 使用 Asynq 重试机制（最多3次）
  - 每次重试前检查加油包当前状态，避免重复更新
  - 3次失败后写入死信队列，发送告警

---

## 并发场景

### Scenario: 并发购买加油包
- **GIVEN** 载体有生效中主套餐（ID=123）
- **WHEN** 两个请求 req1 和 req2 同时购买加油包
- **THEN** 系统使用行锁：
  ```sql
  SELECT * FROM package_usage
  WHERE carrier_id=? AND package_type='formal' AND status IN (0,1)
  ORDER BY status DESC, priority ASC
  FOR UPDATE
  ```
- **AND** req1 和 req2 创建的加油包 master_usage_id 都为 123

### Scenario: 并发主套餐过期和购买加油包
- **GIVEN** 主套餐A（ID=123）即将过期，主套餐B（ID=124）待生效
- **WHEN** 时间到达过期时刻：
  - 请求1：定时任务将主套餐A status=3，触发级联失效
  - 请求2：用户购买加油包
- **THEN** 使用事务隔离：
  - 如果请求2先获取锁 → 加油包 master_usage_id=123，然后被级联失效（status=4）
  - 如果请求1先获取锁 → 主套餐A已无生效中，加油包 master_usage_id=124

### Scenario: 并发更新主套餐有效期和级联失效
- **GIVEN** 主套餐 ID=123，有5个跟随的加油包（has_independent_expiry=false）
- **WHEN** 同时发生：
  - 请求1：主套餐 expires_at 更新为 2027-12-31
  - 请求2：主套餐到期，触发级联失效
- **THEN** 使用行锁 `SELECT * FROM package_usage WHERE id=123 FOR UPDATE`
- **AND** 先完成的操作生效，后完成的操作基于新状态执行

---

## 异常处理

### 1. 级联失效失败

- **错误场景**：主套餐过期时，批量更新加油包 SQL 执行失败
- **处理流程**：
  1. 捕获错误，记录 Error 日志（包含主套餐ID、加油包数量、错误信息）
  2. Asynq 自动重试（最多3次，间隔 10s/30s/60s）
  3. 重试前检查加油包当前状态（避免重复更新）
  4. 3次失败后写入死信队列，发送告警通知
- **返回错误**：不返回给用户（异步任务），仅记录日志

### 2. master_usage_id 不存在

- **错误场景**：加油包的 master_usage_id 指向的主套餐被删除
- **处理流程**：
  1. 加油包激活时检查 `SELECT id FROM package_usage WHERE id=master_usage_id`
  2. 如果不存在 → 返回错误 500，错误码 `MASTER_NOT_FOUND`
  3. 记录 Error 日志（包含加油包ID、master_usage_id、载体信息）
- **返回错误**：`{"code": "MASTER_NOT_FOUND", "msg": "关联的主套餐不存在，请联系管理员"}`

### 3. 同步有效期失败

- **错误场景**：主套餐 expires_at 更新时，批量更新跟随加油包失败
- **处理流程**：
  1. 使用事务包裹主套餐更新和加油包批量更新
  2. 加油包更新失败 → 事务回滚，主套餐 expires_at 不更新
  3. 记录 Error 日志（包含主套餐ID、加油包数量、错误信息）
  4. 返回错误 500，错误码 `SYNC_EXPIRY_FAILED`
- **返回错误**：`{"code": "SYNC_EXPIRY_FAILED", "msg": "更新套餐有效期失败，请稍后重试"}`

### 4. 购买加油包时无主套餐

- **错误场景**：用户购买加油包时，载体无任何主套餐
- **处理流程**：
  1. 查询载体主套餐：`SELECT id FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) LIMIT 1`
  2. 如果无结果 → 返回错误 400，错误码 `ADDON_REQUIRES_MASTER`
- **返回错误**：`{"code": "ADDON_REQUIRES_MASTER", "msg": "必须有主套餐才能购买加油包"}`

---

## 数据一致性保证

### 1. 事务边界

- **主套餐过期 + 级联失效**：使用单个事务，确保原子性
- **主套餐更新有效期 + 同步加油包**：使用单个事务，更新失败则回滚
- **购买加油包 + 关联主套餐**：使用事务，确保 master_usage_id 正确

### 2. 行锁机制

- **查询主套餐时加锁**：`SELECT * FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) FOR UPDATE`
- **更新主套餐有效期时加锁**：`SELECT * FROM package_usage WHERE id=? FOR UPDATE`
- **级联失效时加锁**：`SELECT * FROM package_usage WHERE master_usage_id=? FOR UPDATE`

### 3. 唯一索引

- 已有索引：`idx_carrier_package_type_priority`（carrier_id + package_type + priority）
- 已有索引：`idx_master_usage_id`（master_usage_id）

### 4. 数据校验

- **购买加油包前**：校验 has_independent_expiry 与 duration_days 的一致性
- **激活加油包时**：校验 master_usage_id 是否存在
- **级联失效时**：仅更新 status NOT IN (3, 4) 的加油包（避免重复更新）

---

## 性能指标

| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|------|-------------|---------|--------|
| 购买加油包（主套餐检查） | < 50ms | 100 QPS | 单载体查询 |
| 关联主套餐（查询+插入） | < 100ms | 100 QPS | 单载体查询 + 单条插入 |
| 主套餐过期级联失效 | < 500ms | 10 QPS | 批量更新（平均10个加油包） |
| 主套餐更新有效期同步 | < 300ms | 50 QPS | 批量更新（平均5个加油包） |

---

## 错误码定义

| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| `ADDON_REQUIRES_MASTER` | 400 | 必须有主套餐才能购买加油包 | 购买加油包时无主套餐 |
| `MASTER_NOT_FOUND` | 500 | 关联的主套餐不存在，请联系管理员 | master_usage_id 不存在 |
| `ADDON_MASTER_EXPIRED` | 400 | 关联的主套餐已过期，无法激活加油包 | 尝试激活已失效加油包 |
| `SYNC_EXPIRY_FAILED` | 500 | 更新套餐有效期失败，请稍后重试 | 同步加油包有效期失败 |
| `CASCADE_INVALIDATE_FAILED` | 500 | 级联失效加油包失败，请稍后重试 | 级联失效批量更新失败 |

---

## 数据迁移策略

**激进策略**（开发阶段，保证干净性）：

### 1. ❌ 要删除的字段

目前 `package_usage` 表中可能存在的冗余字段（需确认后删除）：
- 如果有 `parent_usage_id` 字段（旧的父级关联） → **删除**
- 如果有 `linked_usage_ids` 字段（旧的关联列表） → **删除**
- 如果有 `inherit_to_next` 字段（旧的继承标志） → **删除**

### 2. ✅ 新增的字段

在 `package_usage` 表中新增：
```sql
ALTER TABLE package_usage
ADD COLUMN master_usage_id BIGINT DEFAULT NULL COMMENT '主套餐ID（加油包专用）',
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '是否有独立有效期（加油包专用）';

CREATE INDEX idx_master_usage_id ON package_usage(master_usage_id);
```

在 `package` 表中新增：
```sql
ALTER TABLE package
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '加油包是否有独立有效期（仅 package_type=addon 时有效）';
```

### 3. ❌ 要废弃的逻辑

- **废弃旧的加油包关联逻辑**：如果代码中存在通过 `parent_usage_id` 或其他字段关联主套餐的逻辑，全部删除
- **废弃旧的继承逻辑**：如果代码中存在"主套餐切换时加油包继承到新主套餐"的逻辑，全部删除
- **废弃旧的有效期计算逻辑**：如果加油包有效期计算不区分"独立有效期"和"跟随主套餐"，全部重构

### 4. ✅ 历史数据强制转换

```sql
-- Step 1: 历史加油包数据强制关联到当前主套餐
UPDATE package_usage pu_addon
SET master_usage_id = (
    SELECT pu_master.id
    FROM package_usage pu_master
    WHERE pu_master.carrier_id = pu_addon.carrier_id
      AND pu_master.package_type = 'formal'
      AND pu_master.status IN (0, 1)
    ORDER BY pu_master.status DESC, pu_master.priority ASC
    LIMIT 1
)
WHERE pu_addon.package_type = 'addon'
  AND pu_addon.master_usage_id IS NULL;

-- Step 2: 无主套餐的历史加油包强制失效
UPDATE package_usage
SET status = 4,
    invalidated_at = NOW()
WHERE package_type = 'addon'
  AND master_usage_id IS NULL;

-- Step 3: 历史加油包默认为独立有效期模式
UPDATE package_usage
SET has_independent_expiry = true
WHERE package_type = 'addon'
  AND has_independent_expiry IS NULL;

-- Step 4: 已过期主套餐的加油包全部级联失效
UPDATE package_usage pu_addon
SET status = 4,
    invalidated_at = NOW()
FROM package_usage pu_master
WHERE pu_addon.master_usage_id = pu_master.id
  AND pu_master.package_type = 'formal'
  AND pu_master.status = 3  -- 已过期
  AND pu_addon.status NOT IN (3, 4);
```

### 5. ❌ 删除遗留表/字段（确认后执行）

```sql
-- 如果存在旧的关联表，删除
-- DROP TABLE IF EXISTS package_usage_relations;

-- 如果存在冗余字段，删除
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS parent_usage_id;
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS linked_usage_ids;
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS inherit_to_next;
```

### 6. 验证步骤

```sql
-- 验证1：所有加油包都有 master_usage_id（除了已失效的）
SELECT COUNT(*)
FROM package_usage
WHERE package_type = 'addon'
  AND status NOT IN (3, 4)
  AND master_usage_id IS NULL;
-- 预期结果：0

-- 验证2：所有加油包的 master_usage_id 都指向有效的主套餐
SELECT COUNT(*)
FROM package_usage pu_addon
LEFT JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
WHERE pu_addon.package_type = 'addon'
  AND pu_addon.master_usage_id IS NOT NULL
  AND pu_master.id IS NULL;
-- 预期结果：0

-- 验证3：已过期主套餐的加油包都已失效
SELECT COUNT(*)
FROM package_usage pu_addon
JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
WHERE pu_master.status = 3
  AND pu_addon.status NOT IN (3, 4);
-- 预期结果：0

-- 验证4：检查是否还有遗留字段（需根据实际情况调整）
-- SELECT column_name FROM information_schema.columns
-- WHERE table_name = 'package_usage'
--   AND column_name IN ('parent_usage_id', 'linked_usage_ids', 'inherit_to_next');
-- 预期结果：0 rows
```

---

## 测试场景矩阵

| 场景分类 | 测试用例 | 预期结果 |
|---------|---------|---------|
| **依附检查** | 无主套餐购买加油包 | 返回错误 400：ADDON_REQUIRES_MASTER |
| | 有生效中主套餐购买加油包 | 创建成功，master_usage_id=生效中主套餐ID |
| | 只有待生效主套餐购买加油包 | 创建成功，master_usage_id=priority最小的待生效主套餐ID |
| **关联逻辑** | 多个主套餐时购买加油包 | 优先关联生效中主套餐 |
| | 并发购买加油包 | 使用行锁，两个加油包关联到同一主套餐 |
| **独立有效期** | 独立有效期加油包激活 | expires_at = activated_at + duration_days |
| | 独立有效期加油包到期 | status=3（已过期） |
| | 独立有效期加油包未到期但主套餐过期 | status=4（已失效） |
| **跟随主套餐** | 跟随主套餐的加油包激活 | expires_at = master套餐.expires_at |
| | 主套餐更新有效期 | 跟随加油包同步更新 expires_at |
| | 主套餐更新有效期时独立有效期加油包不变 | 独立有效期加油包 expires_at 不变 |
| **级联失效** | 主套餐过期触发级联失效 | 所有关联加油包 status=4 |
| | 独立有效期加油包未到期但主套餐过期 | status=4（已失效） |
| | 已过期加油包不重复失效 | status 保持 3 |
| | 级联失效失败重试 | Asynq 重试3次，失败后进入死信队列 |
| **不继承** | 新主套餐激活后旧加油包不关联 | 旧加油包 master_usage_id 和 status 保持不变 |
| | 为新主套餐购买新加油包 | 新加油包 master_usage_id=新主套餐ID |
| | 尝试激活已失效加油包 | 返回错误 400：ADDON_MASTER_EXPIRED |
| **并发** | 并发购买加油包 | 使用行锁，确保关联到同一主套餐 |
| | 并发主套餐过期和购买加油包 | 事务隔离，先完成的操作生效 |
| **异常** | master_usage_id 不存在 | 返回错误 500：MASTER_NOT_FOUND |
| | 同步有效期失败 | 事务回滚，返回错误 500：SYNC_EXPIRY_FAILED |
| | 级联失效失败 | Asynq 重试，记录日志，发送告警 |

---

## 实现参考

### 购买加油包时的主套餐检查

```go
// Service 层：CheckMasterPackageForAddon
func (s *Service) CheckMasterPackageForAddon(ctx context.Context, carrierID uint) (uint, error) {
    // 查询生效中或待生效的主套餐
    masterUsage, err := s.store.FindMasterPackage(ctx, carrierID)
    if err != nil {
        return 0, errors.Wrap(errors.CodeInternalError, err, "查询主套餐失败")
    }
    if masterUsage == nil {
        return 0, errors.New(errors.CodeInvalidParam, "必须有主套餐才能购买加油包")
    }
    return masterUsage.ID, nil
}

// Store 层：FindMasterPackage
func (s *Store) FindMasterPackage(ctx context.Context, carrierID uint) (*model.PackageUsage, error) {
    var usage model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("carrier_id = ? AND package_type = ? AND status IN (?, ?)",
            carrierID, constants.PackageTypeFormal,
            constants.PackageStatusPending, constants.PackageStatusActive).
        Order("status DESC, priority ASC").  // 优先生效中，然后按 priority
        First(&usage).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    return &usage, nil
}
```

### 主套餐过期时级联失效加油包

```go
// Service 层：CascadeInvalidateAddons
func (s *Service) CascadeInvalidateAddons(ctx context.Context, masterUsageID uint) error {
    tx := s.store.BeginTx(ctx)
    defer tx.Rollback()

    // 批量更新加油包状态
    count, err := s.store.InvalidateAddonsByMaster(ctx, tx, masterUsageID)
    if err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "级联失效加油包失败")
    }

    if err := tx.Commit().Error; err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
    }

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

    return nil
}

// Store 层：InvalidateAddonsByMaster
func (s *Store) InvalidateAddonsByMaster(ctx context.Context, tx *gorm.DB, masterUsageID uint) (int64, error) {
    result := tx.WithContext(ctx).
        Model(&model.PackageUsage{}).
        Where("master_usage_id = ? AND status NOT IN (?, ?)",
            masterUsageID,
            constants.PackageStatusExpired,
            constants.PackageStatusInvalidated).
        Updates(map[string]interface{}{
            "status":         constants.PackageStatusInvalidated,
            "invalidated_at": time.Now(),
        })
    if result.Error != nil {
        return 0, result.Error
    }
    return result.RowsAffected, nil
}
```

### 主套餐更新有效期时同步跟随加油包

```go
// Service 层：SyncAddonExpiry
func (s *Service) SyncAddonExpiry(ctx context.Context, masterUsageID uint, newExpiresAt time.Time) error {
    tx := s.store.BeginTx(ctx)
    defer tx.Rollback()

    // 更新主套餐有效期
    if err := s.store.UpdateExpiry(ctx, tx, masterUsageID, newExpiresAt); err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "更新主套餐有效期失败")
    }

    // 批量更新跟随的加油包
    count, err := s.store.SyncFollowingAddonExpiry(ctx, tx, masterUsageID, newExpiresAt)
    if err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "同步加油包有效期失败")
    }

    if err := tx.Commit().Error; err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
    }

    s.logger.Info("同步加油包有效期成功",
        zap.Uint("master_usage_id", masterUsageID),
        zap.Int64("count", count),
        zap.Time("new_expires_at", newExpiresAt))

    return nil
}

// Store 层：SyncFollowingAddonExpiry
func (s *Store) SyncFollowingAddonExpiry(ctx context.Context, tx *gorm.DB, masterUsageID uint, expiresAt time.Time) (int64, error) {
    result := tx.WithContext(ctx).
        Model(&model.PackageUsage{}).
        Where("master_usage_id = ? AND has_independent_expiry = ?", masterUsageID, false).
        Update("expires_at", expiresAt)
    if result.Error != nil {
        return 0, result.Error
    }
    return result.RowsAffected, nil
}
```

---

**本 Spec 完成**，包含：
- ✅ 业务背景和业务规则
- ✅ 详细场景（依附、关联、独立有效期、跟随主套餐、级联失效、不继承）
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
- ✅ **激进的数据迁移策略**（明确删除字段、废弃逻辑、强制转换）
- ✅ 测试场景矩阵和实现参考
