# Spec: 套餐流量重置周期管理

## 业务背景

### 为什么需要流量重置周期管理

**现状问题**：
- 运营商套餐的流量重置规则多样：按日、按月、按年、不重置
- 套餐有效期与流量重置周期是两个独立维度（如12个月套餐可按月重置流量）
- 不同运营商有特殊规则（如联通按27号重置，而非1号）
- 用户需要清晰知道流量何时重置，避免超额使用

**业务目标**：
- 支持灵活配置流量重置周期（daily/monthly/yearly/none）
- 流量重置周期独立于套餐有效期类型
- 自动调度流量重置任务（定时任务）
- 保留历史流量使用记录，仅重置当前累计值

---

## 业务规则

### 1. 重置周期类型

| data_reset_cycle | 说明 | 重置时间点 | 适用场景 |
|------------------|------|-----------|---------|
| `daily` | 按日重置 | 每天 00:00:00 | 日租卡、按日计费套餐 |
| `monthly` | 按月重置 | 每月1号 00:00:00（联通27号） | 月租套餐、年套餐按月清零 |
| `yearly` | 按年重置 | 每年1月1日 00:00:00 | 年度套餐 |
| `none` | 不重置 | 永不重置 | 一次性流量包 |

### 2. 重置时间点规则

**通用规则**：
```
每日重置：
- 触发时间：每天 00:00:00
- 重置对象：data_reset_cycle=daily AND status=1（生效中）

每月重置：
- 通用触发时间：每月1号 00:00:00
- 联通特殊规则：每月27号 00:00:00
- 重置对象：data_reset_cycle=monthly AND status=1（生效中）

每年重置：
- 触发时间：每年1月1日 00:00:00
- 重置对象：data_reset_cycle=yearly AND status=1（生效中）
```

**联通特殊规则**：
- 如果套餐的 `isp=unicom`（联通），`data_reset_cycle=monthly` → 每月27号00:00:00重置
- 其他运营商按1号重置

### 3. 重置逻辑

重置流量时的操作：

```
重置流程：
1. 查询需要重置的套餐（根据 data_reset_cycle 和 status=1）
2. 批量更新：
   - data_usage_mb = 0
   - last_reset_at = 当前时间
3. 不删除 PackageUsageDailyRecord 历史记录
4. 记录重置日志
```

**不重置的内容**：
- ❌ PackageUsageDailyRecord 历史记录（保留）
- ❌ 套餐有效期（expires_at 不变）
- ❌ 套餐状态（status 不变）
- ✅ 仅重置 data_usage_mb = 0

### 4. 重置条件

仅对以下套餐执行重置：
- `status=1`（生效中）
- `data_reset_cycle != none`
- `expires_at > 当前时间`（未过期）

**不重置的套餐**：
- status=0（待生效）
- status=2（已用完）
- status=3（已过期）
- status=4（已失效）
- data_reset_cycle=none（不重置）

### 5. 流量重置与套餐有效期独立

流量重置周期与套餐有效期类型独立：

| 套餐配置 | 流量重置行为 | 举例 |
|---------|-------------|------|
| 12个月套餐 + monthly | 每月1号重置流量，共重置12次 | 年套餐按月清零 |
| 12个月套餐 + yearly | 激活时清零，12个月内不重置 | 年度总量套餐 |
| 30天套餐 + daily | 每天0点重置流量，共重置30次 | 日租卡 |
| 30天套餐 + none | 30天内累计使用，不重置 | 一次性流量包 |

---

## ADDED Requirements

### Requirement: 支持流量重置周期配置

系统 SHALL 支持为套餐配置流量重置周期（data_reset_cycle），可选值为 daily、monthly、yearly、none。

#### Scenario: 创建按日重置的套餐
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=daily
- **THEN** 系统创建成功，套餐的 data_reset_cycle=daily

#### Scenario: 创建按月重置的套餐
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=monthly
- **THEN** 系统创建成功，套餐的 data_reset_cycle=monthly

#### Scenario: 创建按年重置的套餐
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=yearly
- **THEN** 系统创建成功，套餐的 data_reset_cycle=yearly

#### Scenario: 创建不重置流量的套餐
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=none
- **THEN** 系统创建成功，套餐的 data_reset_cycle=none

#### Scenario: 更新套餐的重置周期配置
- **GIVEN** 套餐 ID=123，data_reset_cycle=monthly
- **WHEN** 管理员更新套餐配置为 data_reset_cycle=daily
- **THEN** 系统更新成功，该套餐后续流量重置遵循新配置
- **AND** 已有的 PackageUsage 不受影响（仍按原配置重置）

### Requirement: 流量重置周期独立于套餐有效期

系统 SHALL 允许套餐的流量重置周期与套餐有效期类型独立配置。

#### Scenario: 12个月套餐按月重置流量
- **GIVEN** 套餐配置为 duration_months=12, data_reset_cycle=monthly
- **WHEN** 套餐在 2026-02-01 激活
- **THEN** 套餐有效期到 2027-01-31，流量在每月1号重置（共12次）

#### Scenario: 12个月套餐按年重置流量
- **GIVEN** 套餐配置为 duration_months=12, data_reset_cycle=yearly
- **WHEN** 套餐在 2026-02-01 激活
- **THEN** 套餐有效期到 2027-01-31，流量仅在激活时清零，12个月内不重置

#### Scenario: 30天套餐按日重置流量
- **GIVEN** 套餐配置为 duration_days=30, data_reset_cycle=daily
- **WHEN** 套餐在 2026-02-01 激活
- **THEN** 套餐有效期到 2026-03-02，流量每天0点重置（共30次）

#### Scenario: 自然月套餐按月重置
- **GIVEN** 套餐配置为 calendar_type=natural_month, duration_months=1, data_reset_cycle=monthly
- **WHEN** 套餐在 2026-02-15 激活
- **THEN** 套餐有效期到 2026-02-28，流量在3月1日不重置（因为套餐已过期）

### Requirement: 每日流量重置调度

系统 SHALL 每天 00:00:00 自动重置所有 data_reset_cycle=daily 的生效中套餐的 data_usage_mb 为 0。

#### Scenario: 每日流量重置成功
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
- **AND** 存在3个 data_reset_cycle=daily 且 status=1 的套餐
- **WHEN** 定时任务执行
- **THEN** 系统批量更新这3个套餐：
  - data_usage_mb = 0
  - last_reset_at = 2026-02-11 00:00:00

#### Scenario: 非每日重置套餐不受影响
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
- **AND** 存在 data_reset_cycle=monthly 的套餐
- **WHEN** 定时任务执行
- **THEN** 这些套餐的 data_usage_mb 不变

#### Scenario: 待生效和已过期套餐不重置
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
- **AND** 存在 data_reset_cycle=daily 但 status=0（待生效）的套餐
- **AND** 存在 data_reset_cycle=daily 但 status=3（已过期）的套餐
- **WHEN** 定时任务执行
- **THEN** 这些套餐不被重置

#### Scenario: 每日重置记录到日志
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
- **AND** 重置了5个套餐
- **WHEN** 定时任务执行完成
- **THEN** 系统记录 Info 日志：
  - "每日流量重置完成，重置套餐数量：5"

### Requirement: 每月流量重置调度

系统 SHALL 每月1号 00:00:00 自动重置所有 data_reset_cycle=monthly 的生效中套餐的 data_usage_mb 为 0。

#### Scenario: 每月流量重置成功
- **GIVEN** 系统时间到达 2026-03-01 00:00:00
- **AND** 存在5个 data_reset_cycle=monthly 且 status=1 的套餐（非联通）
- **WHEN** 定时任务执行
- **THEN** 系统批量更新这5个套餐：
  - data_usage_mb = 0
  - last_reset_at = 2026-03-01 00:00:00

#### Scenario: 联通运营商特殊重置周期
- **GIVEN** 系统时间到达 2026-02-27 00:00:00
- **AND** 存在3个 data_reset_cycle=monthly 且 isp=unicom 且 status=1 的套餐
- **WHEN** 定时任务执行
- **THEN** 系统批量更新这3个套餐：
  - data_usage_mb = 0
  - last_reset_at = 2026-02-27 00:00:00

#### Scenario: 跨月边界流量统计
- **GIVEN** 套餐在 2026-01-31 23:50:00 使用了 5GB 流量
- **AND** data_usage_mb = 5GB
- **WHEN** 系统时间到达 2026-02-01 00:00:00，触发重置
- **THEN** 套餐的 data_usage_mb 重置为 0
- **AND** 1月31日的 PackageUsageDailyRecord 仍存在（data_usage_mb=5GB）

#### Scenario: 跨年边界流量重置
- **GIVEN** 套餐在 2026-12-31 使用了 10GB 流量
- **WHEN** 系统时间到达 2027-01-01 00:00:00，触发重置
- **THEN** 套餐的 data_usage_mb 重置为 0
- **AND** 2026年12月的日记录仍存在

### Requirement: 每年流量重置调度

系统 SHALL 每年1月1日 00:00:00 自动重置所有 data_reset_cycle=yearly 的生效中套餐的 data_usage_mb 为 0。

#### Scenario: 每年流量重置成功
- **GIVEN** 系统时间到达 2027-01-01 00:00:00
- **AND** 存在2个 data_reset_cycle=yearly 且 status=1 的套餐
- **WHEN** 定时任务执行
- **THEN** 系统批量更新这2个套餐：
  - data_usage_mb = 0
  - last_reset_at = 2027-01-01 00:00:00

#### Scenario: 12个月套餐按年重置
- **GIVEN** 套餐在 2026-06-15 激活，duration_months=12，data_reset_cycle=yearly
- **AND** expires_at=2027-06-15
- **WHEN** 系统时间到达 2027-01-01 00:00:00
- **THEN** 套餐流量重置（因为仍在有效期内）

#### Scenario: 已过期的年套餐不重置
- **GIVEN** 套餐在 2025-06-15 激活，duration_months=12，data_reset_cycle=yearly
- **AND** expires_at=2026-06-15（已过期）
- **WHEN** 系统时间到达 2027-01-01 00:00:00
- **THEN** 套餐不被重置（status=3）

### Requirement: 不重置流量的套餐

系统 SHALL 对 data_reset_cycle=none 的套餐，在整个有效期内不重置 data_usage_mb。

#### Scenario: 套餐有效期内流量不重置
- **GIVEN** 套餐 data_reset_cycle=none，duration_days=30
- **AND** 套餐在 2026-02-01 激活
- **WHEN** 套餐在30天内使用了 80GB 流量
- **THEN** data_usage_mb 累计为 80GB，期间从未重置

#### Scenario: 新激活时流量清零
- **GIVEN** 套餐 data_reset_cycle=none
- **WHEN** 套餐首次激活
- **THEN** data_usage_mb 初始化为 0

#### Scenario: 不重置套餐不被定时任务影响
- **GIVEN** 系统时间到达每日/每月/每年重置时刻
- **AND** 存在 data_reset_cycle=none 的套餐
- **WHEN** 定时任务执行
- **THEN** 这些套餐不被查询，不执行任何操作

### Requirement: 流量重置周期信息可查询

系统 SHALL 在套餐详情和使用记录 API 中返回 data_reset_cycle 和 last_reset_at。

#### Scenario: 查询套餐流量重置配置
- **WHEN** 用户通过 GET /api/admin/packages/:id 查询套餐
- **THEN** 响应包含：
  ```json
  {
    "data_reset_cycle": "monthly",
    "isp": "unicom"
  }
  ```

#### Scenario: 查询套餐使用记录的重置信息
- **WHEN** 用户通过 GET /api/admin/package-usage/:id 查询套餐使用记录
- **THEN** 响应包含：
  ```json
  {
    "data_reset_cycle": "monthly",
    "last_reset_at": "2026-02-27T00:00:00Z",
    "data_usage_mb": 1024
  }
  ```

#### Scenario: 客户端查询流量重置信息
- **WHEN** 客户通过 GET /api/customer/package-usage 查询自己的套餐
- **THEN** 响应包含 data_reset_cycle 和 last_reset_at，方便用户知道下次重置时间

### Requirement: 流量重置不影响日记录

系统 SHALL 在流量重置时保留历史日记录（PackageUsageDailyRecord），仅重置当前 data_usage_mb。

#### Scenario: 重置后历史记录可查
- **GIVEN** 套餐在 2026-02-28 使用了 10GB 流量
- **AND** PackageUsageDailyRecord 记录了 2026-02-28 的 10GB 使用量
- **WHEN** 系统时间到达 2026-03-01 00:00:00，触发重置
- **THEN** 套餐的 data_usage_mb 重置为 0
- **AND** 2026-02-28 的 PackageUsageDailyRecord 记录仍存在且可查询

#### Scenario: 重置后新的流量使用
- **GIVEN** 套餐在 2026-03-01 00:00:00 重置后，data_usage_mb=0
- **WHEN** 2026-03-01 10:00:00 使用了 2GB 流量
- **THEN** 套餐的 data_usage_mb=2GB
- **AND** 写入新的 PackageUsageDailyRecord（date=2026-03-01, data_usage_mb=2GB）

---

## 边界条件

### 1. 跨月边界

- **场景**：套餐在月末23:59:59使用流量，次月0:00:00触发重置
- **处理**：
  - 重置任务在 00:00:00 执行
  - 月末最后一笔流量扣减已提交（日记录已写入）
  - 重置时仅清零 data_usage_mb，不影响日记录

### 2. 跨年边界

- **场景**：套餐在12月31日使用流量，1月1日触发年度重置
- **处理**：
  - 与跨月边界相同
  - 年度重置只重置 data_reset_cycle=yearly 的套餐
  - 月度重置套餐不受年度重置影响

### 3. 并发流量扣减和重置

- **场景**：重置任务执行的同时，有流量扣减请求
- **处理**：
  - 使用行锁：`SELECT * FROM package_usage WHERE id=? FOR UPDATE`
  - 先完成的操作生效，后完成的操作基于新值执行
  - 如果重置先完成 → 流量扣减从0开始累加
  - 如果扣减先完成 → 重置清零后续扣减继续

### 4. 定时任务执行延迟

- **场景**：定时任务因系统负载延迟到 00:05:00 才执行
- **处理**：
  - 仍按计划重置所有符合条件的套餐
  - last_reset_at 记录实际重置时间（00:05:00）
  - 不影响下次重置周期（仍按 00:00:00 计算）

### 5. 套餐过期与重置时间重合

- **场景**：套餐在 2026-03-01 00:00:00 过期，同时触发月度重置
- **处理**：
  - 过期任务将套餐 status=3
  - 重置任务查询时排除 status=3 的套餐
  - 不执行重置操作

---

## 并发场景

### Scenario: 并发流量扣减和重置
- **GIVEN** 套餐 ID=123，data_usage_mb=5GB
- **WHEN** 同时发生：
  - 请求1：流量扣减 1GB
  - 请求2：定时任务重置流量
- **THEN** 使用行锁：
  ```sql
  SELECT * FROM package_usage WHERE id=123 FOR UPDATE
  ```
- **AND** 如果请求1先完成：
  - data_usage_mb = 6GB
  - 请求2重置 → data_usage_mb = 0
- **AND** 如果请求2先完成：
  - data_usage_mb = 0
  - 请求1扣减 → data_usage_mb = 1GB

### Scenario: 并发多套餐重置
- **GIVEN** 有1000个 data_reset_cycle=daily 的套餐
- **WHEN** 定时任务批量重置
- **THEN** 系统：
  - 分批处理（每批100个）
  - 每批使用单独事务
  - 失败批次记录日志，不影响其他批次

---

## 异常处理

### 1. 重置任务失败

- **错误场景**：定时任务执行时数据库连接失败
- **处理流程**：
  1. 捕获错误，记录 Error 日志（包含失败原因、影响套餐数量）
  2. 使用 Asynq 重试机制（最多3次，间隔 10s/30s/60s）
  3. 重试前检查套餐 last_reset_at（避免重复重置）
  4. 3次失败后写入死信队列，发送告警
- **返回错误**：不返回给用户（定时任务），仅记录日志

### 2. 批量重置部分失败

- **错误场景**：批量重置1000个套餐，第500个套餐更新失败
- **处理流程**：
  1. 分批处理（每批100个），每批独立事务
  2. 失败批次回滚，其他批次正常提交
  3. 记录失败批次的套餐ID列表
  4. Asynq 重试失败批次
- **返回错误**：不返回给用户（定时任务），仅记录日志

### 3. last_reset_at 更新失败

- **错误场景**：data_usage_mb 重置成功，但 last_reset_at 更新失败
- **处理流程**：
  1. 使用事务包裹两个更新操作
  2. 任何一个失败 → 事务回滚，全部不更新
  3. 记录 Error 日志
  4. Asynq 重试
- **返回错误**：不返回给用户（定时任务），仅记录日志

---

## 数据一致性保证

### 1. 事务边界

- **批量重置套餐**：每批使用单独事务，确保原子性
- **流量扣减 + 重置并发**：使用行锁，确保顺序执行

### 2. 行锁机制

- **重置套餐时加锁**：`SELECT * FROM package_usage WHERE id IN (...) FOR UPDATE`
- **流量扣减时加锁**：`SELECT * FROM package_usage WHERE id=? FOR UPDATE`

### 3. 幂等性保证

- **重置任务幂等**：重试前检查 last_reset_at，如果已是今日则跳过
- **示例**：
  ```sql
  UPDATE package_usage
  SET data_usage_mb = 0, last_reset_at = NOW()
  WHERE data_reset_cycle = 'daily'
    AND status = 1
    AND (last_reset_at IS NULL OR DATE(last_reset_at) < CURDATE());
  ```

### 4. 数据校验

- **重置前**：校验套餐 status=1（生效中）
- **重置前**：校验套餐 expires_at > 当前时间（未过期）
- **重置后**：校验 data_usage_mb=0 且 last_reset_at 已更新

---

## 性能指标

| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|------|-------------|---------|--------|
| 每日流量重置（单批） | < 500ms | 定时任务 | 批量更新（100个套餐/批） |
| 每月流量重置（单批） | < 500ms | 定时任务 | 批量更新（100个套餐/批） |
| 每年流量重置（单批） | < 500ms | 定时任务 | 批量更新（100个套餐/批） |
| 查询重置周期配置 | < 50ms | 100 QPS | 单套餐查询 |

---

## 错误码定义

| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| `RESET_TASK_FAILED` | 500 | 流量重置任务失败，请联系管理员 | 定时任务执行失败 |
| `INVALID_RESET_CYCLE` | 400 | 无效的重置周期配置 | data_reset_cycle 值不合法 |
| `LAST_RESET_AT_UPDATE_FAILED` | 500 | 更新重置时间失败 | last_reset_at 更新失败 |

---

## 数据迁移策略

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

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

目前 `package` 表中可能存在的冗余字段（需确认后删除）：
- 如果有 `reset_interval` 字段（旧的重置间隔） → **删除**
- 如果有 `reset_day` 字段（旧的重置日期） → **删除**

目前 `package_usage` 表中可能存在的冗余字段（需确认后删除）：
- 如果有 `last_reset_date` 字段（旧的重置日期，非时间戳） → **删除**

### 2. ✅ 新增的字段

在 `package` 表中新增：
```sql
ALTER TABLE package
ADD COLUMN data_reset_cycle VARCHAR(10) DEFAULT 'none' COMMENT '流量重置周期（daily/monthly/yearly/none）',
ADD COLUMN isp VARCHAR(20) DEFAULT NULL COMMENT '运营商（unicom/mobile/telecom，用于特殊重置规则）';

CREATE INDEX idx_data_reset_cycle ON package(data_reset_cycle);
```

在 `package_usage` 表中新增：
```sql
ALTER TABLE package_usage
ADD COLUMN last_reset_at DATETIME DEFAULT NULL COMMENT '最后一次流量重置时间';

CREATE INDEX idx_last_reset_at ON package_usage(last_reset_at);
```

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

- **废弃旧的重置逻辑**：如果代码中存在通过 `reset_interval` 或 `reset_day` 字段计算重置的逻辑，全部删除
- **废弃旧的定时任务**：如果存在旧的流量重置定时任务，全部删除
- **废弃旧的重置时间字段**：统一使用 `last_reset_at`（DATETIME），删除其他相关字段

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

```sql
-- Step 1: 历史套餐的重置周期初始化
-- 假设历史套餐默认为按月重置（需根据实际业务规则调整）
UPDATE package
SET data_reset_cycle = 'monthly'
WHERE data_reset_cycle IS NULL;

-- 如果历史有特殊类型，可以根据 duration 或其他字段推断：
-- 例如：duration_days=1 → data_reset_cycle='daily'
UPDATE package
SET data_reset_cycle = 'daily'
WHERE duration_days = 1
  AND data_reset_cycle IS NULL;

-- Step 2: 历史套餐的运营商初始化
-- 假设历史套餐默认为移动（需根据实际业务规则调整）
UPDATE package
SET isp = 'mobile'
WHERE isp IS NULL;

-- Step 3: 历史 PackageUsage 的 last_reset_at 初始化
-- 如果有旧的 last_reset_date 字段，转换为 last_reset_at
-- UPDATE package_usage
-- SET last_reset_at = STR_TO_DATE(last_reset_date, '%Y-%m-%d')
-- WHERE last_reset_date IS NOT NULL;

-- 如果没有旧字段，根据 activated_at 推断：
-- 按月重置：last_reset_at = 当前月的1号
-- 按日重置：last_reset_at = 今天0点
-- 按年重置：last_reset_at = 今年1月1日
-- 不重置：last_reset_at = NULL

UPDATE package_usage pu
JOIN package p ON pu.package_id = p.id
SET pu.last_reset_at = DATE_FORMAT(CURDATE(), '%Y-%m-01 00:00:00')
WHERE p.data_reset_cycle = 'monthly'
  AND pu.status = 1
  AND pu.last_reset_at IS NULL;

UPDATE package_usage pu
JOIN package p ON pu.package_id = p.id
SET pu.last_reset_at = DATE_FORMAT(CURDATE(), '%Y-%m-%d 00:00:00')
WHERE p.data_reset_cycle = 'daily'
  AND pu.status = 1
  AND pu.last_reset_at IS NULL;

UPDATE package_usage pu
JOIN package p ON pu.package_id = p.id
SET pu.last_reset_at = DATE_FORMAT(CURDATE(), '%Y-01-01 00:00:00')
WHERE p.data_reset_cycle = 'yearly'
  AND pu.status = 1
  AND pu.last_reset_at IS NULL;

-- Step 4: data_reset_cycle=none 的套餐不设置 last_reset_at
-- （保持 NULL）
```

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

```sql
-- 如果存在旧的重置相关字段，删除
-- ALTER TABLE package DROP COLUMN IF EXISTS reset_interval;
-- ALTER TABLE package DROP COLUMN IF EXISTS reset_day;
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS last_reset_date;
```

### 6. 验证步骤

```sql
-- 验证1：所有套餐都有 data_reset_cycle
SELECT COUNT(*)
FROM package
WHERE data_reset_cycle IS NULL;
-- 预期结果：0

-- 验证2：data_reset_cycle 值合法
SELECT COUNT(*)
FROM package
WHERE data_reset_cycle NOT IN ('daily', 'monthly', 'yearly', 'none');
-- 预期结果：0

-- 验证3：生效中套餐的 last_reset_at 不为空（除了 data_reset_cycle=none）
SELECT COUNT(*)
FROM package_usage pu
JOIN package p ON pu.package_id = p.id
WHERE pu.status = 1
  AND p.data_reset_cycle != 'none'
  AND pu.last_reset_at IS NULL;
-- 预期结果：0

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

---

## 测试场景矩阵

| 场景分类 | 测试用例 | 预期结果 |
|---------|---------|---------|
| **配置重置周期** | 创建按日重置套餐 | data_reset_cycle=daily |
| | 创建按月重置套餐 | data_reset_cycle=monthly |
| | 创建按年重置套餐 | data_reset_cycle=yearly |
| | 创建不重置套餐 | data_reset_cycle=none |
| **每日重置** | 每日0点重置 | data_usage_mb=0, last_reset_at=今日0点 |
| | 非每日重置套餐不受影响 | data_usage_mb 不变 |
| | 待生效/已过期套餐不重置 | data_usage_mb 不变 |
| **每月重置** | 每月1号重置 | data_usage_mb=0, last_reset_at=本月1号0点 |
| | 联通特殊规则（27号重置） | data_usage_mb=0, last_reset_at=本月27号0点 |
| | 跨月边界流量统计 | 日记录保留，data_usage_mb 重置 |
| **每年重置** | 每年1月1日重置 | data_usage_mb=0, last_reset_at=今年1月1日0点 |
| | 已过期年套餐不重置 | data_usage_mb 不变 |
| **不重置** | 有效期内流量累计 | data_usage_mb 持续累加 |
| | 定时任务不影响 | data_usage_mb 不变 |
| **历史记录** | 重置后历史记录可查 | PackageUsageDailyRecord 存在 |
| | 重置后新流量使用 | 新日记录写入 |
| **并发** | 并发流量扣减和重置 | 使用行锁，顺序执行 |
| | 并发多套餐重置 | 分批处理，失败批次不影响其他 |
| **异常** | 重置任务失败 | Asynq 重试，记录日志 |
| | 批量重置部分失败 | 失败批次回滚，其他批次正常 |

---

## 实现参考

### 每日流量重置定时任务

```go
// Handler: HandleDailyReset
func (h *DataResetHandler) HandleDailyReset(ctx context.Context, task *asynq.Task) error {
    const batchSize = 100

    // 1. 查询需要重置的套餐ID列表
    usageIDs, err := h.packageUsageStore.ListDailyResetUsageIDs(ctx)
    if err != nil {
        return fmt.Errorf("list daily reset usage ids failed: %w", err)
    }

    if len(usageIDs) == 0 {
        h.logger.Info("无需要每日重置的套餐")
        return nil
    }

    // 2. 分批重置
    totalCount := 0
    failedCount := 0

    for i := 0; i < len(usageIDs); i += batchSize {
        end := i + batchSize
        if end > len(usageIDs) {
            end = len(usageIDs)
        }

        batchIDs := usageIDs[i:end]

        // 使用独立事务
        tx := h.db.Begin()
        err := h.resetUsageBatch(ctx, tx, batchIDs)
        if err != nil {
            tx.Rollback()
            failedCount += len(batchIDs)
            h.logger.Error("批量重置失败", zap.Error(err), zap.Ints("batch_ids", batchIDs))
            continue
        }

        if err := tx.Commit().Error; err != nil {
            failedCount += len(batchIDs)
            h.logger.Error("提交事务失败", zap.Error(err))
            continue
        }

        totalCount += len(batchIDs)
    }

    h.logger.Info("每日流量重置完成",
        zap.Int("total_count", totalCount),
        zap.Int("failed_count", failedCount))

    if failedCount > 0 {
        return fmt.Errorf("部分套餐重置失败，失败数量：%d", failedCount)
    }

    return nil
}

// Store 层：ListDailyResetUsageIDs
func (s *Store) ListDailyResetUsageIDs(ctx context.Context) ([]int, error) {
    var ids []int
    err := s.db.WithContext(ctx).
        Table("package_usage pu").
        Select("pu.id").
        Joins("JOIN package p ON pu.package_id = p.id").
        Where("p.data_reset_cycle = ?", constants.DataResetCycleDaily).
        Where("pu.status = ?", constants.PackageStatusActive).
        Where("pu.expires_at > ?", time.Now()).
        Where("(pu.last_reset_at IS NULL OR DATE(pu.last_reset_at) < CURDATE())").  // 幂等性
        Pluck("pu.id", &ids).Error
    return ids, err
}

// Store 层：resetUsageBatch
func (h *DataResetHandler) resetUsageBatch(ctx context.Context, tx *gorm.DB, ids []int) error {
    return tx.WithContext(ctx).
        Model(&model.PackageUsage{}).
        Where("id IN (?)", ids).
        Updates(map[string]interface{}{
            "data_usage_mb": 0,
            "last_reset_at": time.Now(),
        }).Error
}
```

### 每月流量重置定时任务（含联通特殊规则）

```go
// Handler: HandleMonthlyReset
func (h *DataResetHandler) HandleMonthlyReset(ctx context.Context, task *asynq.Task) error {
    // 判断今天是几号
    today := time.Now().Day()

    // 1. 重置非联通套餐（每月1号）
    if today == 1 {
        if err := h.resetMonthlyUsages(ctx, ""); err != nil {
            h.logger.Error("非联通套餐每月重置失败", zap.Error(err))
            return err
        }
    }

    // 2. 重置联通套餐（每月27号）
    if today == 27 {
        if err := h.resetMonthlyUsages(ctx, constants.ISPUnicom); err != nil {
            h.logger.Error("联通套餐每月重置失败", zap.Error(err))
            return err
        }
    }

    return nil
}

// resetMonthlyUsages: 重置按月重置的套餐
func (h *DataResetHandler) resetMonthlyUsages(ctx context.Context, isp string) error {
    const batchSize = 100

    // 查询需要重置的套餐ID列表
    usageIDs, err := h.packageUsageStore.ListMonthlyResetUsageIDs(ctx, isp)
    if err != nil {
        return fmt.Errorf("list monthly reset usage ids failed: %w", err)
    }

    if len(usageIDs) == 0 {
        h.logger.Info("无需要每月重置的套餐", zap.String("isp", isp))
        return nil
    }

    // 分批重置（逻辑与每日重置相同）
    // ...
    return nil
}

// Store 层：ListMonthlyResetUsageIDs
func (s *Store) ListMonthlyResetUsageIDs(ctx context.Context, isp string) ([]int, error) {
    query := s.db.WithContext(ctx).
        Table("package_usage pu").
        Select("pu.id").
        Joins("JOIN package p ON pu.package_id = p.id").
        Where("p.data_reset_cycle = ?", constants.DataResetCycleMonthly).
        Where("pu.status = ?", constants.PackageStatusActive).
        Where("pu.expires_at > ?", time.Now())

    if isp != "" {
        // 联通特殊规则
        query = query.Where("p.isp = ?", isp)
    } else {
        // 非联通套餐
        query = query.Where("p.isp != ?", constants.ISPUnicom)
    }

    // 幂等性：避免重复重置
    query = query.Where("(pu.last_reset_at IS NULL OR DATE(pu.last_reset_at) < CURDATE())")

    var ids []int
    err := query.Pluck("pu.id", &ids).Error
    return ids, err
}
```

---

**本 Spec 完成**，包含：
- ✅ 业务背景和业务规则
- ✅ 详细场景（每日/每月/每年重置、不重置、联通特殊规则）
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
- ✅ **激进的数据迁移策略**（明确删除字段、废弃逻辑、强制转换）
- ✅ 测试场景矩阵和实现参考
