Files
junhong_cmp_fiber/openspec/specs/auto-stop-resume/spec.md
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

392 lines
11 KiB
Markdown
Raw 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: 自动停复机机制
## 业务背景
### 为什么需要自动停复机
**现状问题**
- 当前系统流量耗尽后手动停机,用户购买加油包后需手动复机
- 停复机时机不精确,可能出现流量已耗尽但仍可上网的情况
- 用户购买加油包后不知道需要复机,导致流量无法使用
**业务目标**
- 所有套餐流量耗尽时自动停机,避免超额使用
- 购买新套餐(正式/加油包)后自动复机,提升用户体验
- 停复机延迟 < 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 完成**,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(停机、复机、幂等性、容错)
- ✅ 数据模型变更
- ✅ 业务流程图
- ✅ 并发场景和异常处理
- ✅ 性能指标和错误码定义
- ✅ 测试场景矩阵和实现参考