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 常量定义
28 KiB
28 KiB
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 != noneexpires_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 响应包含:
{ "data_reset_cycle": "monthly", "isp": "unicom" }
Scenario: 查询套餐使用记录的重置信息
- WHEN 用户通过 GET /api/admin/package-usage/:id 查询套餐使用记录
- THEN 响应包含:
{ "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 使用行锁:
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. 重置任务失败
- 错误场景:定时任务执行时数据库连接失败
- 处理流程:
- 捕获错误,记录 Error 日志(包含失败原因、影响套餐数量)
- 使用 Asynq 重试机制(最多3次,间隔 10s/30s/60s)
- 重试前检查套餐 last_reset_at(避免重复重置)
- 3次失败后写入死信队列,发送告警
- 返回错误:不返回给用户(定时任务),仅记录日志
2. 批量重置部分失败
- 错误场景:批量重置1000个套餐,第500个套餐更新失败
- 处理流程:
- 分批处理(每批100个),每批独立事务
- 失败批次回滚,其他批次正常提交
- 记录失败批次的套餐ID列表
- Asynq 重试失败批次
- 返回错误:不返回给用户(定时任务),仅记录日志
3. last_reset_at 更新失败
- 错误场景:data_usage_mb 重置成功,但 last_reset_at 更新失败
- 处理流程:
- 使用事务包裹两个更新操作
- 任何一个失败 → 事务回滚,全部不更新
- 记录 Error 日志
- Asynq 重试
- 返回错误:不返回给用户(定时任务),仅记录日志
数据一致性保证
1. 事务边界
- 批量重置套餐:每批使用单独事务,确保原子性
- 流量扣减 + 重置并发:使用行锁,确保顺序执行
2. 行锁机制
- 重置套餐时加锁:
SELECT * FROM package_usage WHERE id IN (...) FOR UPDATE - 流量扣减时加锁:
SELECT * FROM package_usage WHERE id=? FOR UPDATE
3. 幂等性保证
- 重置任务幂等:重试前检查 last_reset_at,如果已是今日则跳过
- 示例:
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 表中新增:
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 表中新增:
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. ✅ 历史数据强制转换
-- 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. ❌ 删除遗留表/字段(确认后执行)
-- 如果存在旧的重置相关字段,删除
-- 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. 验证步骤
-- 验证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 重试,记录日志 |
| 批量重置部分失败 | 失败批次回滚,其他批次正常 |
实现参考
每日流量重置定时任务
// 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
}
每月流量重置定时任务(含联通特殊规则)
// 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 完成,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(每日/每月/每年重置、不重置、联通特殊规则)
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
- ✅ 激进的数据迁移策略(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考