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