All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入 - 实现套餐流量重置服务(日/月/年周期重置) - 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑 - 新增订单创建幂等性防重(Redis 业务键 + 分布式锁) - 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求 - 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南) - 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录 - 新增 queue types 抽象和 Redis 常量定义
810 lines
28 KiB
Markdown
810 lines
28 KiB
Markdown
# 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 完成**,包含:
|
||
- ✅ 业务背景和业务规则
|
||
- ✅ 详细场景(每日/每月/每年重置、不重置、联通特殊规则)
|
||
- ✅ 边界条件和并发场景
|
||
- ✅ 异常处理和数据一致性保证
|
||
- ✅ 性能指标和错误码定义
|
||
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||
- ✅ 测试场景矩阵和实现参考
|