Files
huang ad095d73f3
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m8s
docs: 同步能力规范 — 新增购买/重置后复机 spec,更新停复机和排队激活规范
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-31 18:12:52 +08:00

414 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 分钟要求)
## 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 分钟 |
| **Depleted 场景** | 已耗尽套餐到期 + 有排队套餐 | 耗尽套餐→Expired激活下一个 |
| | 已耗尽套餐到期 + 无排队套餐 | 耗尽套餐→Expired载体进入无套餐状态 |
---
## 迭代更新fix-stop-resume-lifecycle-engine
### MODIFIED Requirement: 同时只能有一个生效中的主套餐
**到期检测扩展**:过期检测 SHALL 同时覆盖 `status=1`(生效中)和 `status=2`(已耗尽)的主套餐。当 `expires_at <= now` 时,无论套餐当前是生效中还是已耗尽,均视为到期,触发下一个排队套餐激活。
查询条件:`WHERE status IN (1,2) AND expires_at <= now AND master_usage_id IS NULL`
#### Scenario: 已耗尽Depleted主套餐到期后触发下一个激活
- **GIVEN** 卡 C2 有已耗尽主套餐(`status=2 Depleted``expires_at=过去时间`)和一个预购排队套餐(`status=0`
- **WHEN** 过期检测任务扫描(查询条件:`status IN (1,2) AND expires_at <= now AND master_usage_id IS NULL`
- **THEN** 耗尽套餐更新为 `status=3 Expired`,加油包级联失效,预购排队套餐激活为 `status=1`
- **AND** 如卡因流量耗尽停机且已实名,触发自动复机
#### Scenario: 已耗尽套餐到期时加油包级联失效
- **GIVEN** 卡 C3 有耗尽主套餐(`status=2``expires_at=过去时间`),以及绑定到该主套餐的 1 个加油包(`status=1`,尚有剩余流量)
- **WHEN** 过期检测任务处理该主套餐
- **THEN** 主套餐更新为 `status=3`,加油包更新为 `status=4 Invalidated`(即使尚有剩余流量)
- **AND** 下一个排队主套餐激活
#### Scenario: 已耗尽套餐到期且无下一个排队套餐
- **GIVEN** 卡 C4 有耗尽主套餐(`status=2``expires_at=过去时间`),无排队套餐
- **WHEN** 过期检测任务处理该主套餐
- **THEN** 耗尽套餐更新为 `status=3`,加油包级联失效,无新套餐激活
- **AND** 卡的业务状态更新为 `IotCardStatusSuspended=4`(已停用)