# Spec: 主套餐排队生效机制 ## 业务背景 现有套餐系统允许同一载体(设备/卡)同时存在多个生效中的主套餐,导致流量统计混乱、停机条件不明确等问题。本规范引入主套餐排队机制,确保: 1. **同一时刻只能有一个生效中主套餐**:避免多套餐并存的业务混乱 2. **后续购买自动排队**:用户提前购买多个主套餐(囤货),按购买顺序自动激活 3. **无缝衔接**:当前主套餐过期后,系统自动激活下一个,无需人工干预 ## 业务规则 ### 主套餐识别规则 - **主套餐定义**:`package_type=formal` 且 `master_usage_id IS NULL` - **加油包定义**:`package_type=addon` 或 `master_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 为待生效主套餐分配递增的 priority,priority 数字越小优先级越高。 **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 队列堆积 > 1000:Warning 告警,检查 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 - **伪代码**: ```sql 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. 过期检测分批处理 ```sql -- 每次最多处理 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. 数据库索引优化 ```sql -- 过期检测索引 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_at` 和 `expires_at` 2. **订单服务彻底重构**: - **删除** 现有 `activatePackage` 函数中的立即激活逻辑 - 所有主套餐购买统一走排队逻辑(首个除外) - 不保留旧的激活方式 3. **API 破坏性变更**: - 订单创建接口行为变更:后续主套餐购买不再立即生效 - 响应中新增 `priority` 和 `estimated_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(正确识别闰年)