feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入 - 实现套餐流量重置服务(日/月/年周期重置) - 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑 - 新增订单创建幂等性防重(Redis 业务键 + 分布式锁) - 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求 - 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南) - 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录 - 新增 queue types 抽象和 Redis 常量定义
This commit is contained in:
391
openspec/specs/auto-stop-resume/spec.md
Normal file
391
openspec/specs/auto-stop-resume/spec.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# Spec: 自动停复机机制
|
||||
|
||||
## 业务背景
|
||||
|
||||
### 为什么需要自动停复机
|
||||
|
||||
**现状问题**:
|
||||
- 当前系统流量耗尽后手动停机,用户购买加油包后需手动复机
|
||||
- 停复机时机不精确,可能出现流量已耗尽但仍可上网的情况
|
||||
- 用户购买加油包后不知道需要复机,导致流量无法使用
|
||||
|
||||
**业务目标**:
|
||||
- 所有套餐流量耗尽时自动停机,避免超额使用
|
||||
- 购买新套餐(正式/加油包)后自动复机,提升用户体验
|
||||
- 停复机延迟 < 2分钟,确保及时性
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 1. 停机触发条件
|
||||
|
||||
```
|
||||
停机条件 = (所有生效套餐流量 = 0) AND (卡当前状态 = active)
|
||||
```
|
||||
|
||||
**详细逻辑**:
|
||||
```sql
|
||||
-- 检查是否有剩余流量
|
||||
SELECT COUNT(*) FROM tb_package_usage
|
||||
WHERE iot_card_id = ?
|
||||
AND status = 1 -- 生效中
|
||||
AND data_usage_mb < data_limit_mb;
|
||||
|
||||
-- 如果 COUNT = 0,触发停机
|
||||
```
|
||||
|
||||
### 2. 复机触发条件
|
||||
|
||||
```
|
||||
复机条件 = (存在可用流量套餐) AND (卡当前状态 = stopped)
|
||||
```
|
||||
|
||||
**可用流量套餐定义**:
|
||||
```sql
|
||||
status='active' AND remaining_data_amount > 0
|
||||
```
|
||||
|
||||
### 3. 停复机延迟要求
|
||||
|
||||
- **目标延迟**:< 2分钟(从触发条件到完成停复机)
|
||||
- **实现方式**:流量检查后同步调用停复机接口(不走异步队列)
|
||||
|
||||
### 4. 运营商接口容错
|
||||
|
||||
- 停机/复机失败时:
|
||||
- 重试3次(间隔 1s, 2s, 4s)
|
||||
- 仍失败:记录错误日志,人工介入
|
||||
- **不阻塞**套餐激活流程
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 流量耗尽自动停机
|
||||
|
||||
系统 SHALL 在主套餐和所有加油包流量都用完时,调用运营商接口停机。
|
||||
|
||||
#### Scenario: 所有套餐流量耗尽触发停机
|
||||
- **GIVEN** 卡 C1 有主套餐(剩余0MB)和加油包(剩余0MB),卡状态为 active
|
||||
- **WHEN** 轮询系统检查停机条件
|
||||
- **THEN** 系统执行停机操作:
|
||||
1. 调用运营商停机接口
|
||||
2. 更新 IotCard.network_status=0(已停机)
|
||||
3. 记录 stopped_at 时间
|
||||
4. 记录 stop_reason="traffic_exhausted"
|
||||
5. 记录操作日志
|
||||
|
||||
#### Scenario: 有剩余流量时不停机
|
||||
- **GIVEN** 主套餐流量用完,但加油包剩余1GB
|
||||
- **WHEN** 轮询系统检查停机条件
|
||||
- **THEN** 系统查询到有剩余流量,不触发停机
|
||||
|
||||
#### Scenario: 停机接口调用失败重试
|
||||
- **GIVEN** 所有套餐流量用完,需要停机
|
||||
- **WHEN** 调用运营商停机接口失败(网络超时)
|
||||
- **THEN** 系统重试3次(间隔1s/2s/4s)
|
||||
- **AND** 3次都失败后记录 Error 日志,告警通知运维
|
||||
|
||||
#### Scenario: 停机幂等性
|
||||
- **GIVEN** 卡已停机(network_status=0)
|
||||
- **WHEN** 轮询系统再次检测到流量用完
|
||||
- **THEN** 系统检测到已停机,跳过停机调用
|
||||
|
||||
### Requirement: 购买套餐自动复机
|
||||
|
||||
系统 SHALL 在购买新套餐(正式/加油包)激活后,自动调用运营商接口复机。
|
||||
|
||||
#### Scenario: 购买加油包自动复机
|
||||
- **GIVEN** 卡 C1 已停机(network_status=0,stopped_at=2026-02-10 10:00)
|
||||
- **WHEN** 用户购买加油包,激活成功(status=active)
|
||||
- **THEN** 系统执行复机操作:
|
||||
1. 调用运营商复机接口
|
||||
2. 更新 IotCard.network_status=1(正常)
|
||||
3. 记录 resumed_at 时间
|
||||
4. 清空 stopped_at
|
||||
5. 记录操作日志
|
||||
|
||||
#### Scenario: 复机幂等性
|
||||
- **GIVEN** 卡 C1 已停机
|
||||
- **WHEN** 用户快速购买2个加油包
|
||||
- **THEN** 第1个加油包激活 → 触发复机成功
|
||||
- **AND** 第2个加油包激活 → 检测到已是 active 状态,跳过复机
|
||||
- **AND** 运营商复机接口调用仅1次
|
||||
|
||||
#### Scenario: 购买主套餐自动复机
|
||||
- **GIVEN** 卡 C1 已停机,主套餐过期
|
||||
- **WHEN** 用户购买新主套餐,激活成功
|
||||
- **THEN** 系统自动触发复机
|
||||
|
||||
#### Scenario: 复机失败容错
|
||||
- **GIVEN** 卡已停机
|
||||
- **WHEN** 购买加油包激活,但运营商复机接口返回失败
|
||||
- **THEN** 系统重试3次
|
||||
- **AND** 仍失败后:
|
||||
- 套餐激活成功(status=active)
|
||||
- 卡状态仍为 stopped
|
||||
- 错误日志已记录
|
||||
- 告警通知运维
|
||||
|
||||
### Requirement: 复机延迟 < 2分钟
|
||||
|
||||
系统 SHALL 确保从套餐激活到卡复机完成的延迟 < 2分钟。
|
||||
|
||||
#### Scenario: 复机延迟达标
|
||||
- **GIVEN** 加油包在 2026-02-10 10:00:00 激活成功
|
||||
- **WHEN** 系统同步调用复机接口
|
||||
- **THEN** 复机完成时间 < 2026-02-10 10:02:00(延迟 < 2分钟)
|
||||
|
||||
#### Scenario: 复机失败后重试延迟
|
||||
- **GIVEN** 加油包激活,第1次复机调用失败
|
||||
- **WHEN** 系统重试3次(间隔1s/2s/4s)
|
||||
- **THEN** 复机在第3次重试成功,总延迟约7秒
|
||||
|
||||
---
|
||||
|
||||
## 数据模型变更
|
||||
|
||||
### tb_iot_card 新增字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| stopped_at | timestamp | 停机时间,NULL=未停机 |
|
||||
| resumed_at | timestamp | 最近复机时间 |
|
||||
| stop_reason | varchar(50) | 停机原因:`traffic_exhausted`, `manual`, `arrears` |
|
||||
|
||||
**索引**:
|
||||
- 无需索引(非查询字段,仅用于审计)
|
||||
|
||||
---
|
||||
|
||||
## 业务流程
|
||||
|
||||
### 流程1:流量耗尽停机
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[流量上报] --> B{所有套餐流量=0?}
|
||||
B -->|是| C{卡状态=active?}
|
||||
B -->|否| Z[结束]
|
||||
C -->|是| D[调用运营商停机接口]
|
||||
C -->|否| Z
|
||||
D --> E{停机成功?}
|
||||
E -->|是| F[更新卡状态=stopped]
|
||||
E -->|否| G[重试3次]
|
||||
F --> H[记录stopped_at]
|
||||
G --> E
|
||||
```
|
||||
|
||||
### 流程2:购买加油包复机
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[加油包激活成功] --> B{卡状态=stopped?}
|
||||
B -->|是| C[调用运营商复机接口]
|
||||
B -->|否| Z[跳过复机]
|
||||
C --> D{复机成功?}
|
||||
D -->|是| E[更新卡状态=active]
|
||||
D -->|否| F[重试3次]
|
||||
E --> G[清空stopped_at]
|
||||
F --> D
|
||||
F -->|3次失败| H[记录错误日志]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 并发场景
|
||||
|
||||
### Scenario: 并发停复机
|
||||
- **GIVEN** 卡流量刚好用完,同时用户购买加油包
|
||||
- **WHEN** 停机任务和复机任务并发执行
|
||||
- **THEN** 使用数据库行锁:
|
||||
```sql
|
||||
SELECT * FROM iot_card WHERE id=? FOR UPDATE
|
||||
```
|
||||
- **AND** 后执行的操作覆盖前一个操作的状态
|
||||
|
||||
### Scenario: 复机任务重复执行
|
||||
- **GIVEN** 用户购买2个加油包,触发2次复机
|
||||
- **WHEN** 第1次复机成功,卡状态=active
|
||||
- **THEN** 第2次复机检测到卡状态=active,跳过调用
|
||||
|
||||
---
|
||||
|
||||
## 异常处理
|
||||
|
||||
### 1. 停机接口超时
|
||||
|
||||
- **场景**:运营商停机接口响应超时(>5秒)
|
||||
- **处理**:
|
||||
1. 记录 Error 日志(包含卡号、超时时间)
|
||||
2. 重试3次,间隔1s/2s/4s
|
||||
3. 3次都失败:记录到死信队列,告警通知
|
||||
- **用户影响**:卡可能仍可上网(停机未成功)
|
||||
|
||||
### 2. 复机接口失败
|
||||
|
||||
- **场景**:运营商复机接口返回业务错误(如卡状态异常)
|
||||
- **处理**:
|
||||
1. 记录 Error 日志(包含卡号、错误码、错误消息)
|
||||
2. 重试3次
|
||||
3. 3次都失败:套餐激活成功,但卡保持停机状态
|
||||
4. 告警通知运维人工介入
|
||||
- **用户影响**:购买加油包后仍无法上网
|
||||
|
||||
### 3. 停复机状态不一致
|
||||
|
||||
- **场景**:系统记录已停机,但运营商侧仍正常
|
||||
- **处理**:
|
||||
1. 轮询系统定期同步卡状态
|
||||
2. 检测到不一致时记录 Warning 日志
|
||||
3. 自动修正系统状态(以运营商侧为准)
|
||||
- **修正频率**:每小时同步一次
|
||||
|
||||
---
|
||||
|
||||
## 性能指标
|
||||
|
||||
| 操作 | 目标响应时间 | 监控指标 |
|
||||
|------|------------|---------|
|
||||
| 停机接口调用 | < 5秒 | 运营商API耗时 |
|
||||
| 复机接口调用 | < 5秒 | 运营商API耗时 |
|
||||
| 停机条件检查 | < 50ms | SELECT COUNT查询耗时 |
|
||||
| 端到端停机延迟 | < 2分钟 | 流量用完到停机完成 |
|
||||
| 端到端复机延迟 | < 2分钟 | 套餐激活到复机完成 |
|
||||
|
||||
---
|
||||
|
||||
## 错误码定义
|
||||
|
||||
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||
|--------|------------|---------|------|
|
||||
| CodeInternal | 500 | 停机操作失败,请重试 | 运营商停机接口失败 |
|
||||
| CodeInternal | 500 | 复机操作失败,请重试 | 运营商复机接口失败 |
|
||||
|
||||
---
|
||||
|
||||
## 测试场景矩阵
|
||||
|
||||
| 维度 | 场景 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| **停机** | 所有套餐流量用完 | 自动停机 |
|
||||
| | 主套餐用完+加油包剩余 | 不停机 |
|
||||
| | 停机接口失败 | 重试3次,失败告警 |
|
||||
| | 已停机重复检测 | 跳过停机 |
|
||||
| **复机** | 购买加油包 | 自动复机 |
|
||||
| | 购买主套餐 | 自动复机 |
|
||||
| | 复机接口失败 | 重试3次,套餐激活成功,卡保持停机 |
|
||||
| | 并发购买2个加油包 | 复机接口调用1次 |
|
||||
| **延迟** | 复机延迟 | < 2分钟 |
|
||||
| | 停机延迟 | < 2分钟 |
|
||||
| **异常** | 停机超时 | 重试后告警 |
|
||||
| | 状态不一致 | 轮询同步修正 |
|
||||
|
||||
---
|
||||
|
||||
## 实现参考
|
||||
|
||||
### Service 层:CheckAndStop
|
||||
|
||||
```go
|
||||
func (s *Service) CheckAndStopCard(ctx context.Context, cardID uint) error {
|
||||
// 1. 查询卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 检查卡状态
|
||||
if card.NetworkStatus != constants.NetworkStatusActive {
|
||||
return nil // 已停机,跳过
|
||||
}
|
||||
|
||||
// 3. 检查是否有剩余流量
|
||||
hasAvailableData, err := s.packageUsageStore.HasAvailableData(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasAvailableData {
|
||||
return nil // 有剩余流量,不停机
|
||||
}
|
||||
|
||||
// 4. 调用运营商停机接口(带重试)
|
||||
err = s.carrierClient.StopCard(ctx, card.ICCID, 3)
|
||||
if err != nil {
|
||||
s.logger.Error("停机失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 更新卡状态
|
||||
err = s.iotCardStore.UpdateStopStatus(ctx, cardID, time.Now(), "traffic_exhausted")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. 记录审计日志
|
||||
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||
OperationType: "card_stop",
|
||||
OperationDesc: "流量耗尽自动停机",
|
||||
TargetID: cardID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Service 层:ResumeCard
|
||||
|
||||
```go
|
||||
func (s *Service) ResumeCardIfStopped(ctx context.Context, cardID uint) error {
|
||||
// 1. 查询卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 检查卡状态
|
||||
if card.NetworkStatus != constants.NetworkStatusStopped {
|
||||
return nil // 未停机,跳过
|
||||
}
|
||||
|
||||
// 3. 调用运营商复机接口(带重试)
|
||||
err = s.carrierClient.ResumeCard(ctx, card.ICCID, 3)
|
||||
if err != nil {
|
||||
s.logger.Error("复机失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
// 复机失败不阻塞套餐激活
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 更新卡状态
|
||||
err = s.iotCardStore.UpdateResumeStatus(ctx, cardID, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 记录审计日志
|
||||
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||
OperationType: "card_resume",
|
||||
OperationDesc: "购买套餐自动复机",
|
||||
TargetID: cardID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**本 Spec 完成**,包含:
|
||||
- ✅ 业务背景和业务规则
|
||||
- ✅ 详细场景(停机、复机、幂等性、容错)
|
||||
- ✅ 数据模型变更
|
||||
- ✅ 业务流程图
|
||||
- ✅ 并发场景和异常处理
|
||||
- ✅ 性能指标和错误码定义
|
||||
- ✅ 测试场景矩阵和实现参考
|
||||
Reference in New Issue
Block a user