# Spec: 主套餐排队生效机制

## 业务背景

现有套餐系统允许同一载体（设备/卡）同时存在多个生效中的主套餐，导致流量统计混乱、停机条件不明确等问题。本规范引入主套餐排队机制，确保：

1. **同一时刻只能有一个生效中主套餐**：避免多套餐并存的业务混乱
2. **后续购买自动排队**：用户提前购买多个主套餐（囤货），按购买顺序自动激活
3. **无缝衔接**：当前主套餐过期后，系统自动激活下一个，无需人工干预

## 业务规则

### 主套餐识别规则
- **主套餐定义**：`package_type=formal` 且 `master_usage_id IS NULL`
- **加油包定义**：`package_type=addon` 或 `master_usage_id IS NOT NULL`

### Priority 分配规则
1. **首个主套餐**：priority=1，立即激活（status=1）
2. **后续主套餐**：priority=MAX(当前主套餐 priority)+1，待生效（status=0）
3. **Priority 全局唯一**：同一载体的所有主套餐 priority 不重复

### 激活顺序规则
1. **按 priority 升序激活**：priority=1 → priority=2 → priority=3 ...
2. **跨状态查询**：轮询系统查询 status=0 且 priority 最小的待生效主套餐
3. **过期检测频率**：每 10 秒执行一次过期检测

### 激活延迟要求
- **目标延迟**：主套餐过期后 1 分钟内完成下一个套餐的激活
- **实际延迟组成**：
  - 过期检测：< 10 秒（轮询间隔）
  - 队列延迟：< 1 秒（Asynq 队列延迟）
  - 激活处理：< 5 秒（数据库更新 + 日志记录）
  - **总延迟** < 20 秒（满足 < 1 分钟要求）

## ADDED Requirements

### Requirement: 同时只能有一个生效中的主套餐
系统 SHALL 确保载体（设备/卡）同一时刻只能有一个 package_type=formal 且 status=1 的套餐。

**数据一致性保证**：
- 购买时检查：查询 `WHERE usage_type=? AND (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NULL`
- 并发控制：使用数据库事务 + 唯一索引（usage_type, carrier_id, status=1）避免并发插入多个生效中主套餐
- 激活时二次检查：激活前再次查询是否有生效中主套餐，避免并发激活

#### Scenario: 首次购买主套餐立即生效
- **GIVEN** 载体无任何主套餐记录
- **WHEN** 用户通过 POST /api/admin/orders 购买主套餐（package_type=formal）
- **THEN** 系统创建 PackageUsage：
  - status=1（生效中）
  - priority=1
  - activated_at=支付完成时间
  - expires_at=根据 calendar_type 计算
  - master_usage_id=NULL
- **AND** 订单状态更新为 completed

#### Scenario: 购买第二个主套餐自动排队
- **GIVEN** 载体已有1个生效中的主套餐（priority=1, status=1）
- **WHEN** 用户购买第2个主套餐
- **THEN** 系统创建 PackageUsage：
  - status=0（待生效）
  - priority=2
  - activated_at=NULL
  - expires_at=NULL
  - master_usage_id=NULL
- **AND** 订单状态更新为 completed

#### Scenario: 购买第三个主套餐继续排队
- **GIVEN** 载体已有1个生效中主套餐（priority=1, status=1）+ 1个待生效主套餐（priority=2, status=0）
- **WHEN** 用户购买第3个主套餐
- **THEN** 系统创建 PackageUsage：
  - status=0（待生效）
  - priority=3
  - activated_at=NULL
  - expires_at=NULL

#### Scenario: 并发购买两个主套餐（并发控制）
- **GIVEN** 载体无任何主套餐记录
- **WHEN** 两个用户同时（< 1秒内）购买主套餐
- **THEN** 第一个请求创建 PackageUsage priority=1, status=1（生效中）
- **AND** 第二个请求创建 PackageUsage priority=2, status=0（待生效）
- **AND** 使用数据库事务保证数据一致性，不会出现两个 priority=1 或两个 status=1

#### Scenario: 查询生效中主套餐（接口验证）
- **GIVEN** 载体有1个 status=1 的主套餐和2个 status=0 的待生效主套餐
- **WHEN** 系统查询生效中主套餐（WHERE status=1 AND master_usage_id IS NULL）
- **THEN** 返回唯一的 status=1 主套餐记录
- **AND** 查询结果数量 = 1

#### Scenario: 违规创建两个生效中主套餐（数据库约束）
- **GIVEN** 数据库有唯一索引（usage_type, iot_card_id, status=1, deleted_at IS NULL）
- **WHEN** 系统尝试插入第二个 status=1 的主套餐（绕过业务逻辑）
- **THEN** 数据库返回唯一约束冲突错误
- **AND** 事务回滚，数据不插入

### Requirement: 主套餐按购买顺序排队
系统 SHALL 为待生效主套餐分配递增的 priority，priority 数字越小优先级越高。

**Priority 计算逻辑**：
```
new_priority = MAX(当前载体所有主套餐的 priority) + 1
```

**边界条件**：
- 首个主套餐 priority=1
- 删除中间 priority 的套餐后，priority 不重新排序（例如删除 priority=2，后续仍从 priority=4 开始）
- priority 最大值不超过 999（业务限制，避免异常）

#### Scenario: Priority 自动递增
- **GIVEN** 载体当前主套餐最大 priority=5
- **WHEN** 用户购买新主套餐
- **THEN** 系统创建 PackageUsage priority=6, status=0

#### Scenario: 首个主套餐 Priority 为 1
- **GIVEN** 载体无任何主套餐记录
- **WHEN** 用户首次购买主套餐
- **THEN** 系统创建 PackageUsage priority=1, status=1（生效中）

#### Scenario: 删除待生效套餐后 Priority 不重排
- **GIVEN** 载体有主套餐 priority=1(status=1), priority=2(status=0), priority=3(status=0)
- **WHEN** 用户删除 priority=2 的待生效套餐（软删除，设置 deleted_at）
- **AND** 再购买新主套餐
- **THEN** 新套餐 priority=4（不重新排序为 priority=2）
- **AND** 激活顺序为 priority=1 → priority=3 → priority=4

#### Scenario: Priority 超过限制（业务异常）
- **GIVEN** 载体当前主套餐最大 priority=999
- **WHEN** 用户尝试购买新主套餐
- **THEN** 系统返回错误 400，错误消息："主套餐排队数量已达上限（999个），请联系客服"
- **AND** 订单创建失败

#### Scenario: 并发分配 Priority（并发控制）
- **GIVEN** 载体当前主套餐最大 priority=5
- **WHEN** 两个用户同时购买主套餐
- **THEN** 第一个请求分配 priority=6
- **AND** 第二个请求分配 priority=7
- **AND** 使用数据库事务 + SELECT FOR UPDATE 避免 priority 重复

### Requirement: 当前主套餐过期后自动激活下一个
系统 SHALL 在主套餐过期（expires_at < now）时，自动激活 priority 最小的待生效主套餐。

**实现机制**：
1. **轮询调度**：Scheduler 每 10 秒执行一次过期检测
2. **过期检测**：查询 `WHERE status=1 AND expires_at <= NOW() AND master_usage_id IS NULL`
3. **状态更新**：将过期主套餐 status 更新为 3（已过期）
4. **查询下一个**：查询 `WHERE status=0 AND master_usage_id IS NULL ORDER BY priority ASC LIMIT 1`
5. **提交任务**：创建 Asynq 任务 `TaskTypePackageQueueActivation`
6. **异步激活**：Asynq Handler 更新 status=1, 计算 activated_at 和 expires_at

**幂等性保证**：
- 任务处理前检查 `status=0`，已激活则直接返回成功
- 使用 Redis 分布式锁（key: `package:activation:lock:{usage_id}`，TTL=30s）

#### Scenario: 自动激活下一个主套餐
- **GIVEN** 当前主套餐 priority=1, status=1, expires_at=2026-02-28 23:59:59
- **AND** 存在待生效主套餐 priority=2, status=0
- **WHEN** 系统时间到达 2026-03-01 00:00:00，轮询系统检测到过期
- **THEN** 系统执行以下操作：
  1. 更新 priority=1 的套餐 status=3（已过期）
  2. 查询 priority=2 的待生效套餐
  3. 提交 Asynq 任务（payload: {usage_id: priority=2的ID}）
  4. Asynq Handler 激活 priority=2 套餐：
     - status=1
     - activated_at=2026-03-01 00:00:10（激活时间，约为 00:00:00 + 10秒延迟）
     - expires_at=根据 calendar_type 计算
- **AND** 激活延迟 < 1 分钟

#### Scenario: 无待生效套餐时不激活
- **GIVEN** 当前主套餐 priority=1, status=1, expires_at=2026-02-28 23:59:59
- **AND** 不存在 status=0 的待生效主套餐
- **WHEN** 系统时间到达 2026-03-01 00:00:00，轮询系统检测到过期
- **THEN** 系统仅更新 priority=1 的套餐 status=3（已过期）
- **AND** 不提交激活任务
- **AND** 载体进入无主套餐状态

#### Scenario: 过期检测批量处理
- **GIVEN** 系统有 10000 个主套餐在 2026-02-28 23:59:59 过期
- **WHEN** 系统时间到达 2026-03-01 00:00:00，轮询系统检测到过期
- **THEN** 系统分批处理（每批 10000 个）：
  1. 批量更新过期主套餐 status=3
  2. 批量查询下一个待生效主套餐（每个载体一个）
  3. 批量提交 Asynq 任务（最多 10000 个任务）
- **AND** 所有任务在 1 分钟内完成激活

#### Scenario: 激活任务失败重试
- **GIVEN** 待生效主套餐 priority=2, status=0
- **WHEN** 轮询系统提交激活任务，但 Asynq Handler 第一次执行失败（例如数据库连接超时）
- **THEN** Asynq 自动重试（MaxRetry=3，间隔 10 秒）
- **AND** 第二次重试成功，套餐激活
- **AND** 总延迟 < 2 分钟（10秒检测 + 10秒首次失败 + 10秒重试成功）

#### Scenario: 激活任务重试耗尽（异常处理）
- **GIVEN** 待生效主套餐 priority=2, status=0
- **WHEN** 轮询系统提交激活任务，Asynq Handler 重试 3 次均失败
- **THEN** Asynq 任务进入死信队列（DLQ）
- **AND** 套餐保持 status=0（待生效）
- **AND** 系统记录 Error 日志，包含完整错误信息和 usage_id
- **AND** 告警通知运维团队，人工介入修复

#### Scenario: 轮询系统重复检测（幂等性保证）
- **GIVEN** 主套餐过期，已提交激活任务，但任务尚未执行完成
- **WHEN** 10 秒后轮询系统再次检测（任务仍在队列中）
- **THEN** 系统查询 status=1 的过期主套餐，结果为空（已更新为 status=3）
- **AND** 不重复提交激活任务

#### Scenario: 激活任务并发执行（幂等性保证）
- **GIVEN** 同一套餐的激活任务被重复提交（例如手动触发 + 自动调度）
- **WHEN** 两个 Asynq Handler 同时执行
- **THEN** 第一个 Handler 获取 Redis 锁，执行激活
- **AND** 第二个 Handler 获取锁失败，等待 30 秒后超时，检查 status=1，直接返回成功
- **AND** 套餐只激活一次

### Requirement: 激活时根据套餐类型计算有效期
系统 SHALL 在排队激活主套餐时，根据 calendar_type 计算 expires_at。

**计算时机**：Asynq Handler 执行激活任务时

**计算逻辑**：
- 自然月套餐：`expires_at = (activated_at 月份 + duration_months) 的月末 23:59:59`
- 按天套餐：`expires_at = (activated_at 日期 + duration_days) 的 23:59:59`

#### Scenario: 排队激活自然月套餐
- **GIVEN** 待生效主套餐 calendar_type=natural_month, duration_months=1
- **WHEN** 2026-03-01 00:00:10 激活
- **THEN** 套餐更新：
  - status=1
  - activated_at=2026-03-01 00:00:10
  - expires_at=2026-03-31 23:59:59
- **AND** 有效期 = 30 天 23 小时 59 分 50 秒

#### Scenario: 排队激活按天套餐
- **GIVEN** 待生效主套餐 calendar_type=by_day, duration_days=30
- **WHEN** 2026-03-01 00:00:10 激活
- **THEN** 套餐更新：
  - status=1
  - activated_at=2026-03-01 00:00:10
  - expires_at=2026-03-30 23:59:59
- **AND** 有效期 = 29 天 23 小时 59 分 49 秒

#### Scenario: 激活时处理闰年（自然月）
- **GIVEN** 待生效主套餐 calendar_type=natural_month, duration_months=1
- **WHEN** 2028-02-01 00:00:10 激活（闰年）
- **THEN** expires_at=2028-02-29 23:59:59（正确识别闰年）

#### Scenario: 激活时处理跨年（自然月）
- **GIVEN** 待生效主套餐 calendar_type=natural_month, duration_months=2
- **WHEN** 2026-12-01 00:00:10 激活
- **THEN** expires_at=2027-02-28 23:59:59（正确跨年）

### Requirement: 主套餐排队调度延迟小于1分钟
系统 SHALL 确保主套餐过期后，待生效套餐在1分钟内完成激活。

**性能指标**：
| 指标 | 目标 | 监控方式 |
|------|------|---------|
| 过期检测延迟 | < 10 秒 | 轮询间隔配置 |
| 任务提交延迟 | < 1 秒 | Asynq 入队时间 |
| 激活处理延迟 | < 5 秒 | Asynq Handler 执行时间 |
| **端到端延迟** | **< 20 秒** | 从过期到激活完成 |

**监控告警**：
- 激活延迟 > 1 分钟：Critical 告警，通知运维团队
- Asynq 队列堆积 > 1000：Warning 告警，检查 Worker 数量
- 激活任务失败率 > 5%：Warning 告警，检查数据库连接

#### Scenario: 排队激活性能达标
- **GIVEN** 主套餐在 2026-02-28 23:59:59 过期
- **WHEN** 轮询系统在 00:00:00 - 00:00:10 之间检测到过期
- **AND** 在 00:00:11 提交 Asynq 任务
- **AND** Asynq Handler 在 00:00:12 - 00:00:17 执行激活
- **THEN** 套餐在 2026-03-01 00:00:17 完成激活
- **AND** 端到端延迟 = 17 秒 < 60 秒

#### Scenario: 高负载下激活延迟（压力测试）
- **GIVEN** 10000 个主套餐同时过期
- **WHEN** 轮询系统检测到过期并提交 10000 个任务
- **AND** Asynq Worker 并发数 = 50
- **THEN** 所有任务在 4 分钟内完成（10000 / 50 / 5秒 ≈ 4 分钟）
- **AND** P99 激活延迟 < 5 分钟（可接受）

#### Scenario: 轮询系统宕机恢复（容错性）
- **GIVEN** 主套餐在 2026-02-28 23:59:59 过期
- **WHEN** 轮询系统在 00:00:00 - 00:10:00 期间宕机
- **AND** 轮询系统在 00:10:01 恢复
- **THEN** 轮询系统检测到过期主套餐（expires_at < 00:10:01）
- **AND** 在 00:10:02 - 00:10:20 完成激活
- **AND** 延迟 = 10 分钟 20 秒（超过目标，但系统自动恢复）

## 数据一致性保证

### 1. 并发购买主套餐
- **机制**：数据库事务 + 唯一索引（usage_type, iot_card_id/device_id, status=1, deleted_at IS NULL）
- **保证**：同一载体同一时刻只能有一个 status=1 的主套餐

### 2. 并发分配 Priority
- **机制**：数据库事务 + SELECT FOR UPDATE
- **伪代码**：
  ```sql
  BEGIN TRANSACTION;
  SELECT MAX(priority) FROM tb_package_usage WHERE ... FOR UPDATE;
  INSERT INTO tb_package_usage (priority) VALUES (max_priority + 1);
  COMMIT;
  ```

### 3. 并发激活同一套餐
- **机制**：Redis 分布式锁（key: `package:activation:lock:{usage_id}`，TTL=30s）
- **保证**：同一套餐只能被激活一次

### 4. 过期检测重复触发
- **机制**：更新 status=3 后，WHERE 条件不再匹配（status=1）
- **保证**：过期主套餐不会重复提交激活任务

## 性能优化策略

### 1. 过期检测分批处理
```sql
-- 每次最多处理 10000 个过期套餐
SELECT id FROM tb_package_usage
WHERE status=1 AND expires_at <= NOW() AND master_usage_id IS NULL
ORDER BY expires_at ASC
LIMIT 10000;
```

### 2. 批量提交 Asynq 任务
- 使用 `Enqueue` 批量提交（每批 1000 个）
- 减少 Redis 往返次数

### 3. Asynq Worker 并发数
- 默认并发数：10
- 高负载时可调整为 50-100
- 监控队列长度动态调整

### 4. 数据库索引优化
```sql
-- 过期检测索引
CREATE INDEX idx_package_usage_expires ON tb_package_usage(status, expires_at, master_usage_id) WHERE deleted_at IS NULL;

-- Priority 查询索引
CREATE INDEX idx_package_usage_priority ON tb_package_usage(iot_card_id, status, priority) WHERE deleted_at IS NULL;
```

## 错误码定义

| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| CodeConflict | 409 | 套餐正在激活中，请稍后重试 | 并发激活冲突 |
| CodeForbidden | 403 | 主套餐排队数量已达上限（999个），请联系客服 | Priority 超限 |
| CodeInternal | 500 | 套餐激活失败，请重试 | 数据库更新失败 |

## 数据迁移策略

**激进策略**（开发阶段）：
1. **历史主套餐数据重新排序**：
   - 查询每个载体的所有主套餐（按 `created_at ASC`）
   - 重新分配 `priority`：第一个=1，第二个=2，以此类推
   - 只保留第一个主套餐 `status=1`（生效中），其余设置为 `status=0`（待生效）
   - 为待生效主套餐清空 `activated_at` 和 `expires_at`

2. **订单服务彻底重构**：
   - **删除** 现有 `activatePackage` 函数中的立即激活逻辑
   - 所有主套餐购买统一走排队逻辑（首个除外）
   - 不保留旧的激活方式

3. **API 破坏性变更**：
   - 订单创建接口行为变更：后续主套餐购买不再立即生效
   - 响应中新增 `priority` 和 `estimated_activation_time` 字段
   - 客户端必须适配新的"待生效"状态展示

## 测试场景矩阵

| 维度 | 场景 | 预期结果 |
|------|------|---------|
| **基础功能** | 首次购买主套餐 | priority=1, status=1 |
| | 购买第2个主套餐 | priority=2, status=0 |
| | 购买第3个主套餐 | priority=3, status=0 |
| **过期激活** | 主套餐过期 + 有待生效套餐 | status=3 → 激活 priority=2 |
| | 主套餐过期 + 无待生效套餐 | status=3，载体无主套餐 |
| **并发场景** | 并发购买两个主套餐 | priority=1(status=1) + priority=2(status=0) |
| | 并发激活同一套餐 | 只激活一次，第二个请求幂等返回 |
| **异常场景** | 激活任务失败 | 重试 3 次，失败进入 DLQ |
| | Priority 超限（999） | 返回错误，拒绝购买 |
| | 轮询系统宕机 | 恢复后自动激活过期套餐 |
| **性能场景** | 单个套餐激活延迟 | < 20 秒 |
| | 10000 个套餐同时过期 | P99 < 5 分钟 |

---

## 补充测试场景（边界条件和异常处理）

### T4. 并发激活竞态（边界情况）
**场景**：两个轮询任务同时检测到可激活套餐

**步骤**：
1. 卡 C1 有2个待激活套餐 P1（优先级1）、P2（优先级2）
2. 两个轮询任务并发执行 `ActivateQueuedPackages(C1)`
3. 验证：
   - P1 仅激活1次（数据库行锁生效）
   - P2 保持待激活状态
   - 无重复调用运营商接口

### T5. 运营商接口超时（异常处理）
**场景**：运营商激活接口超时

**步骤**：
1. Mock 运营商接口延迟10秒
2. 触发套餐激活
3. 验证：
   - 3秒后超时，返回错误
   - 套餐状态仍为 `pending_activation`
   - 下次轮询重试
   - 错误日志已记录

### T6. 月末边界日期（边界情况）
**场景**：联通用户在2月26日购买套餐

**步骤**：
1. 当前日期：2025-02-26
2. 购买自然月套餐（联通，billing_day=27）
3. 验证：
   - 激活时间：2025-02-26
   - 到期时间：2025-02-27 00:00（次日计费日）
   - 有效期仅1天（符合预期）

### T7. 闰年2月激活（边界情况）
**场景**：2028年2月1日激活自然月套餐

**步骤**：
1. 当前日期：2028-02-01（闰年）
2. 激活自然月套餐，duration_months=1
3. 验证：
   - expires_at=2028-02-29 23:59:59（正确识别闰年）
