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

18 KiB
Raw Blame History

Spec: 主套餐排队生效机制

业务背景

现有套餐系统允许同一载体(设备/卡)同时存在多个生效中的主套餐,导致流量统计混乱、停机条件不明确等问题。本规范引入主套餐排队机制,确保:

  1. 同一时刻只能有一个生效中主套餐:避免多套餐并存的业务混乱
  2. 后续购买自动排队:用户提前购买多个主套餐(囤货),按购买顺序自动激活
  3. 无缝衔接:当前主套餐过期后,系统自动激活下一个,无需人工干预

业务规则

主套餐识别规则

  • 主套餐定义package_type=formalmaster_usage_id IS NULL
  • 加油包定义package_type=addonmaster_usage_id IS NOT NULL

Priority 分配规则

  1. 首个主套餐priority=1立即激活status=1
  2. 后续主套餐priority=MAX(当前主套餐 priority)+1待生效status=0
  3. Priority 全局唯一:同一载体的所有主套餐 priority 不重复

激活顺序规则

  1. 按 priority 升序激活priority=1 → priority=2 → priority=3 ...
  2. 跨状态查询:轮询系统查询 status=0 且 priority 最小的待生效主套餐
  3. 过期检测频率:每 10 秒执行一次过期检测

激活延迟要求

  • 目标延迟:主套餐过期后 1 分钟内完成下一个套餐的激活
  • 实际延迟组成
    • 过期检测:< 10 秒(轮询间隔)
    • 队列延迟:< 1 秒Asynq 队列延迟)
    • 激活处理:< 5 秒(数据库更新 + 日志记录)
    • 总延迟 < 20 秒(满足 < 1 分钟要求)

ADDED Requirements

Requirement: 同时只能有一个生效中的主套餐

系统 SHALL 确保载体(设备/卡)同一时刻只能有一个 package_type=formal 且 status=1 的套餐。

数据一致性保证

  • 购买时检查:查询 WHERE usage_type=? AND (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NULL
  • 并发控制:使用数据库事务 + 唯一索引usage_type, carrier_id, status=1避免并发插入多个生效中主套餐
  • 激活时二次检查:激活前再次查询是否有生效中主套餐,避免并发激活

Scenario: 首次购买主套餐立即生效

  • GIVEN 载体无任何主套餐记录
  • WHEN 用户通过 POST /api/admin/orders 购买主套餐package_type=formal
  • THEN 系统创建 PackageUsage
    • status=1生效中
    • priority=1
    • activated_at=支付完成时间
    • expires_at=根据 calendar_type 计算
    • master_usage_id=NULL
  • AND 订单状态更新为 completed

Scenario: 购买第二个主套餐自动排队

  • GIVEN 载体已有1个生效中的主套餐priority=1, status=1
  • WHEN 用户购买第2个主套餐
  • THEN 系统创建 PackageUsage
    • status=0待生效
    • priority=2
    • activated_at=NULL
    • expires_at=NULL
    • master_usage_id=NULL
  • AND 订单状态更新为 completed

Scenario: 购买第三个主套餐继续排队

  • GIVEN 载体已有1个生效中主套餐priority=1, status=1+ 1个待生效主套餐priority=2, status=0
  • WHEN 用户购买第3个主套餐
  • THEN 系统创建 PackageUsage
    • status=0待生效
    • priority=3
    • activated_at=NULL
    • expires_at=NULL

Scenario: 并发购买两个主套餐(并发控制)

  • GIVEN 载体无任何主套餐记录
  • WHEN 两个用户同时(< 1秒内购买主套餐
  • THEN 第一个请求创建 PackageUsage priority=1, status=1生效中
  • AND 第二个请求创建 PackageUsage priority=2, status=0待生效
  • AND 使用数据库事务保证数据一致性,不会出现两个 priority=1 或两个 status=1

Scenario: 查询生效中主套餐(接口验证)

  • GIVEN 载体有1个 status=1 的主套餐和2个 status=0 的待生效主套餐
  • WHEN 系统查询生效中主套餐WHERE status=1 AND master_usage_id IS NULL
  • THEN 返回唯一的 status=1 主套餐记录
  • AND 查询结果数量 = 1

Scenario: 违规创建两个生效中主套餐(数据库约束)

  • GIVEN 数据库有唯一索引usage_type, iot_card_id, status=1, deleted_at IS NULL
  • WHEN 系统尝试插入第二个 status=1 的主套餐(绕过业务逻辑)
  • THEN 数据库返回唯一约束冲突错误
  • AND 事务回滚,数据不插入

Requirement: 主套餐按购买顺序排队

系统 SHALL 为待生效主套餐分配递增的 prioritypriority 数字越小优先级越高。

Priority 计算逻辑

new_priority = MAX(当前载体所有主套餐的 priority) + 1

边界条件

  • 首个主套餐 priority=1
  • 删除中间 priority 的套餐后priority 不重新排序(例如删除 priority=2后续仍从 priority=4 开始)
  • priority 最大值不超过 999业务限制避免异常

Scenario: Priority 自动递增

  • GIVEN 载体当前主套餐最大 priority=5
  • WHEN 用户购买新主套餐
  • THEN 系统创建 PackageUsage priority=6, status=0

Scenario: 首个主套餐 Priority 为 1

  • GIVEN 载体无任何主套餐记录
  • WHEN 用户首次购买主套餐
  • THEN 系统创建 PackageUsage priority=1, status=1生效中

Scenario: 删除待生效套餐后 Priority 不重排

  • GIVEN 载体有主套餐 priority=1(status=1), priority=2(status=0), priority=3(status=0)
  • WHEN 用户删除 priority=2 的待生效套餐(软删除,设置 deleted_at
  • AND 再购买新主套餐
  • THEN 新套餐 priority=4不重新排序为 priority=2
  • AND 激活顺序为 priority=1 → priority=3 → priority=4

Scenario: Priority 超过限制(业务异常)

  • GIVEN 载体当前主套餐最大 priority=999
  • WHEN 用户尝试购买新主套餐
  • THEN 系统返回错误 400错误消息"主套餐排队数量已达上限999个请联系客服"
  • AND 订单创建失败

Scenario: 并发分配 Priority并发控制

  • GIVEN 载体当前主套餐最大 priority=5
  • WHEN 两个用户同时购买主套餐
  • THEN 第一个请求分配 priority=6
  • AND 第二个请求分配 priority=7
  • AND 使用数据库事务 + SELECT FOR UPDATE 避免 priority 重复

Requirement: 当前主套餐过期后自动激活下一个

系统 SHALL 在主套餐过期expires_at < now自动激活 priority 最小的待生效主套餐。

实现机制

  1. 轮询调度Scheduler 每 10 秒执行一次过期检测
  2. 过期检测:查询 WHERE status=1 AND expires_at <= NOW() AND master_usage_id IS NULL
  3. 状态更新:将过期主套餐 status 更新为 3已过期
  4. 查询下一个:查询 WHERE status=0 AND master_usage_id IS NULL ORDER BY priority ASC LIMIT 1
  5. 提交任务:创建 Asynq 任务 TaskTypePackageQueueActivation
  6. 异步激活Asynq Handler 更新 status=1, 计算 activated_at 和 expires_at

幂等性保证

  • 任务处理前检查 status=0,已激活则直接返回成功
  • 使用 Redis 分布式锁key: package:activation:lock:{usage_id}TTL=30s

Scenario: 自动激活下一个主套餐

  • GIVEN 当前主套餐 priority=1, status=1, expires_at=2026-02-28 23:59:59
  • AND 存在待生效主套餐 priority=2, status=0
  • WHEN 系统时间到达 2026-03-01 00:00:00轮询系统检测到过期
  • THEN 系统执行以下操作:
    1. 更新 priority=1 的套餐 status=3已过期
    2. 查询 priority=2 的待生效套餐
    3. 提交 Asynq 任务payload: {usage_id: priority=2的ID}
    4. Asynq Handler 激活 priority=2 套餐:
      • status=1
      • activated_at=2026-03-01 00:00:10激活时间约为 00:00:00 + 10秒延迟
      • expires_at=根据 calendar_type 计算
  • AND 激活延迟 < 1 分钟

Scenario: 无待生效套餐时不激活

  • GIVEN 当前主套餐 priority=1, status=1, expires_at=2026-02-28 23:59:59
  • AND 不存在 status=0 的待生效主套餐
  • WHEN 系统时间到达 2026-03-01 00:00:00轮询系统检测到过期
  • THEN 系统仅更新 priority=1 的套餐 status=3已过期
  • AND 不提交激活任务
  • AND 载体进入无主套餐状态

Scenario: 过期检测批量处理

  • GIVEN 系统有 10000 个主套餐在 2026-02-28 23:59:59 过期
  • WHEN 系统时间到达 2026-03-01 00:00:00轮询系统检测到过期
  • THEN 系统分批处理(每批 10000 个):
    1. 批量更新过期主套餐 status=3
    2. 批量查询下一个待生效主套餐(每个载体一个)
    3. 批量提交 Asynq 任务(最多 10000 个任务)
  • AND 所有任务在 1 分钟内完成激活

Scenario: 激活任务失败重试

  • GIVEN 待生效主套餐 priority=2, status=0
  • WHEN 轮询系统提交激活任务,但 Asynq Handler 第一次执行失败(例如数据库连接超时)
  • THEN Asynq 自动重试MaxRetry=3间隔 10 秒)
  • AND 第二次重试成功,套餐激活
  • AND 总延迟 < 2 分钟10秒检测 + 10秒首次失败 + 10秒重试成功

Scenario: 激活任务重试耗尽(异常处理)

  • GIVEN 待生效主套餐 priority=2, status=0
  • WHEN 轮询系统提交激活任务Asynq Handler 重试 3 次均失败
  • THEN Asynq 任务进入死信队列DLQ
  • AND 套餐保持 status=0待生效
  • AND 系统记录 Error 日志,包含完整错误信息和 usage_id
  • AND 告警通知运维团队,人工介入修复

Scenario: 轮询系统重复检测(幂等性保证)

  • GIVEN 主套餐过期,已提交激活任务,但任务尚未执行完成
  • WHEN 10 秒后轮询系统再次检测(任务仍在队列中)
  • THEN 系统查询 status=1 的过期主套餐,结果为空(已更新为 status=3
  • AND 不重复提交激活任务

Scenario: 激活任务并发执行(幂等性保证)

  • GIVEN 同一套餐的激活任务被重复提交(例如手动触发 + 自动调度)
  • WHEN 两个 Asynq Handler 同时执行
  • THEN 第一个 Handler 获取 Redis 锁,执行激活
  • AND 第二个 Handler 获取锁失败,等待 30 秒后超时,检查 status=1直接返回成功
  • AND 套餐只激活一次

Requirement: 激活时根据套餐类型计算有效期

系统 SHALL 在排队激活主套餐时,根据 calendar_type 计算 expires_at。

计算时机Asynq Handler 执行激活任务时

计算逻辑

  • 自然月套餐:expires_at = (activated_at 月份 + duration_months) 的月末 23:59:59
  • 按天套餐:expires_at = (activated_at 日期 + duration_days) 的 23:59:59

Scenario: 排队激活自然月套餐

  • GIVEN 待生效主套餐 calendar_type=natural_month, duration_months=1
  • WHEN 2026-03-01 00:00:10 激活
  • THEN 套餐更新:
    • status=1
    • activated_at=2026-03-01 00:00:10
    • expires_at=2026-03-31 23:59:59
  • AND 有效期 = 30 天 23 小时 59 分 50 秒

Scenario: 排队激活按天套餐

  • GIVEN 待生效主套餐 calendar_type=by_day, duration_days=30
  • WHEN 2026-03-01 00:00:10 激活
  • THEN 套餐更新:
    • status=1
    • activated_at=2026-03-01 00:00:10
    • expires_at=2026-03-30 23:59:59
  • AND 有效期 = 29 天 23 小时 59 分 49 秒

Scenario: 激活时处理闰年(自然月)

  • GIVEN 待生效主套餐 calendar_type=natural_month, duration_months=1
  • WHEN 2028-02-01 00:00:10 激活(闰年)
  • THEN expires_at=2028-02-29 23:59:59正确识别闰年

Scenario: 激活时处理跨年(自然月)

  • GIVEN 待生效主套餐 calendar_type=natural_month, duration_months=2
  • WHEN 2026-12-01 00:00:10 激活
  • THEN expires_at=2027-02-28 23:59:59正确跨年

Requirement: 主套餐排队调度延迟小于1分钟

系统 SHALL 确保主套餐过期后待生效套餐在1分钟内完成激活。

性能指标

指标 目标 监控方式
过期检测延迟 < 10 秒 轮询间隔配置
任务提交延迟 < 1 秒 Asynq 入队时间
激活处理延迟 < 5 秒 Asynq Handler 执行时间
端到端延迟 < 20 秒 从过期到激活完成

监控告警

  • 激活延迟 > 1 分钟Critical 告警,通知运维团队
  • Asynq 队列堆积 > 1000Warning 告警,检查 Worker 数量
  • 激活任务失败率 > 5%Warning 告警,检查数据库连接

Scenario: 排队激活性能达标

  • GIVEN 主套餐在 2026-02-28 23:59:59 过期
  • WHEN 轮询系统在 00:00:00 - 00:00:10 之间检测到过期
  • AND 在 00:00:11 提交 Asynq 任务
  • AND Asynq Handler 在 00:00:12 - 00:00:17 执行激活
  • THEN 套餐在 2026-03-01 00:00:17 完成激活
  • AND 端到端延迟 = 17 秒 < 60 秒

Scenario: 高负载下激活延迟(压力测试)

  • GIVEN 10000 个主套餐同时过期
  • WHEN 轮询系统检测到过期并提交 10000 个任务
  • AND Asynq Worker 并发数 = 50
  • THEN 所有任务在 4 分钟内完成10000 / 50 / 5秒 ≈ 4 分钟)
  • AND P99 激活延迟 < 5 分钟(可接受)

Scenario: 轮询系统宕机恢复(容错性)

  • GIVEN 主套餐在 2026-02-28 23:59:59 过期
  • WHEN 轮询系统在 00:00:00 - 00:10:00 期间宕机
  • AND 轮询系统在 00:10:01 恢复
  • THEN 轮询系统检测到过期主套餐expires_at < 00:10:01
  • AND 在 00:10:02 - 00:10:20 完成激活
  • AND 延迟 = 10 分钟 20 秒(超过目标,但系统自动恢复)

数据一致性保证

1. 并发购买主套餐

  • 机制:数据库事务 + 唯一索引usage_type, iot_card_id/device_id, status=1, deleted_at IS NULL
  • 保证:同一载体同一时刻只能有一个 status=1 的主套餐

2. 并发分配 Priority

  • 机制:数据库事务 + SELECT FOR UPDATE
  • 伪代码
    BEGIN TRANSACTION;
    SELECT MAX(priority) FROM tb_package_usage WHERE ... FOR UPDATE;
    INSERT INTO tb_package_usage (priority) VALUES (max_priority + 1);
    COMMIT;
    

3. 并发激活同一套餐

  • 机制Redis 分布式锁key: package:activation:lock:{usage_id}TTL=30s
  • 保证:同一套餐只能被激活一次

4. 过期检测重复触发

  • 机制:更新 status=3 后WHERE 条件不再匹配status=1
  • 保证:过期主套餐不会重复提交激活任务

性能优化策略

1. 过期检测分批处理

-- 每次最多处理 10000 个过期套餐
SELECT id FROM tb_package_usage
WHERE status=1 AND expires_at <= NOW() AND master_usage_id IS NULL
ORDER BY expires_at ASC
LIMIT 10000;

2. 批量提交 Asynq 任务

  • 使用 Enqueue 批量提交(每批 1000 个)
  • 减少 Redis 往返次数

3. Asynq Worker 并发数

  • 默认并发数10
  • 高负载时可调整为 50-100
  • 监控队列长度动态调整

4. 数据库索引优化

-- 过期检测索引
CREATE INDEX idx_package_usage_expires ON tb_package_usage(status, expires_at, master_usage_id) WHERE deleted_at IS NULL;

-- Priority 查询索引
CREATE INDEX idx_package_usage_priority ON tb_package_usage(iot_card_id, status, priority) WHERE deleted_at IS NULL;

错误码定义

错误码 HTTP 状态码 错误消息 场景
CodeConflict 409 套餐正在激活中,请稍后重试 并发激活冲突
CodeForbidden 403 主套餐排队数量已达上限999个请联系客服 Priority 超限
CodeInternal 500 套餐激活失败,请重试 数据库更新失败

数据迁移策略

激进策略(开发阶段):

  1. 历史主套餐数据重新排序

    • 查询每个载体的所有主套餐(按 created_at ASC
    • 重新分配 priority:第一个=1第二个=2以此类推
    • 只保留第一个主套餐 status=1(生效中),其余设置为 status=0(待生效)
    • 为待生效主套餐清空 activated_atexpires_at
  2. 订单服务彻底重构

    • 删除 现有 activatePackage 函数中的立即激活逻辑
    • 所有主套餐购买统一走排队逻辑(首个除外)
    • 不保留旧的激活方式
  3. API 破坏性变更

    • 订单创建接口行为变更:后续主套餐购买不再立即生效
    • 响应中新增 priorityestimated_activation_time 字段
    • 客户端必须适配新的"待生效"状态展示

测试场景矩阵

维度 场景 预期结果
基础功能 首次购买主套餐 priority=1, status=1
购买第2个主套餐 priority=2, status=0
购买第3个主套餐 priority=3, status=0
过期激活 主套餐过期 + 有待生效套餐 status=3 → 激活 priority=2
主套餐过期 + 无待生效套餐 status=3载体无主套餐
并发场景 并发购买两个主套餐 priority=1(status=1) + priority=2(status=0)
并发激活同一套餐 只激活一次,第二个请求幂等返回
异常场景 激活任务失败 重试 3 次,失败进入 DLQ
Priority 超限999 返回错误,拒绝购买
轮询系统宕机 恢复后自动激活过期套餐
性能场景 单个套餐激活延迟 < 20 秒
10000 个套餐同时过期 P99 < 5 分钟

补充测试场景(边界条件和异常处理)

T4. 并发激活竞态(边界情况)

场景:两个轮询任务同时检测到可激活套餐

步骤

  1. 卡 C1 有2个待激活套餐 P1优先级1、P2优先级2
  2. 两个轮询任务并发执行 ActivateQueuedPackages(C1)
  3. 验证:
    • P1 仅激活1次数据库行锁生效
    • P2 保持待激活状态
    • 无重复调用运营商接口

T5. 运营商接口超时(异常处理)

场景:运营商激活接口超时

步骤

  1. Mock 运营商接口延迟10秒
  2. 触发套餐激活
  3. 验证:
    • 3秒后超时返回错误
    • 套餐状态仍为 pending_activation
    • 下次轮询重试
    • 错误日志已记录

T6. 月末边界日期(边界情况)

场景联通用户在2月26日购买套餐

步骤

  1. 当前日期2025-02-26
  2. 购买自然月套餐联通billing_day=27
  3. 验证:
    • 激活时间2025-02-26
    • 到期时间2025-02-27 00:00次日计费日
    • 有效期仅1天符合预期

T7. 闰年2月激活边界情况

场景2028年2月1日激活自然月套餐

步骤

  1. 当前日期2028-02-01闰年
  2. 激活自然月套餐duration_months=1
  3. 验证:
    • expires_at=2028-02-29 23:59:59正确识别闰年