Files
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

28 KiB
Raw Blame History

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 响应包含:
    {
      "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 写入新的 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 使用行锁:
    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如果已是今日则跳过
  • 示例
    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_intervalreset_day 字段计算重置的逻辑,全部删除
  • 废弃旧的定时任务:如果存在旧的流量重置定时任务,全部删除
  • 废弃旧的重置时间字段:统一使用 last_reset_atDATETIME删除其他相关字段

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

-- 验证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 重试,记录日志
批量重置部分失败 失败批次回滚,其他批次正常

实现参考

每日流量重置定时任务

// 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 完成,包含:

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