移除所有测试代码和测试要求
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s

**变更说明**:
- 删除所有 *_test.go 文件(单元测试、集成测试、验收测试、流程测试)
- 删除整个 tests/ 目录
- 更新 CLAUDE.md:用"测试禁令"章节替换所有测试要求
- 删除测试生成 Skill (openspec-generate-acceptance-tests)
- 删除测试生成命令 (opsx:gen-tests)
- 更新 tasks.md:删除所有测试相关任务

**新规范**:
-  禁止编写任何形式的自动化测试
-  禁止创建 *_test.go 文件
-  禁止在任务中包含测试相关工作
-  仅当用户明确要求时才编写测试

**原因**:
业务系统的正确性通过人工验证和生产环境监控保证,测试代码维护成本高于价值。

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 17:13:42 +08:00
parent 804145332b
commit 353621d923
218 changed files with 11787 additions and 41983 deletions

View File

@@ -0,0 +1,809 @@
# 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 完成**,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(每日/每月/每年重置、不重置、联通特殊规则)
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
-**激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考