Files
junhong_cmp_fiber/openspec/specs/package-data-reset/spec.md
huang c665f32976
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入
- 实现套餐流量重置服务(日/月/年周期重置)
- 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑
- 新增订单创建幂等性防重(Redis 业务键 + 分布式锁)
- 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求
- 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南)
- 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录
- 新增 queue types 抽象和 Redis 常量定义
2026-02-12 14:24:15 +08:00

810 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=123data_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=12data_reset_cycle=yearly
- **AND** expires_at=2027-06-15
- **WHEN** 系统时间到达 2027-01-01 00:00:00
- **THEN** 套餐流量重置(因为仍在有效期内)
#### Scenario: 已过期的年套餐不重置
- **GIVEN** 套餐在 2025-06-15 激活duration_months=12data_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=noneduration_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** 写入新的 PackageUsageDailyRecorddate=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=123data_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
-- 验证2data_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 完成**,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(每日/每月/每年重置、不重置、联通特殊规则)
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
-**激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考