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 常量定义
368 lines
11 KiB
Markdown
368 lines
11 KiB
Markdown
# Spec: 客户视图流量查询
|
||
|
||
## 业务背景
|
||
|
||
### 为什么需要客户视图流量查询
|
||
|
||
**现状问题**:
|
||
- 客户无法清晰看到主套餐和加油包的分别使用情况
|
||
- 流量汇总不准确(包含已失效加油包)
|
||
- 客户端需要多次调用 API 才能获取完整流量信息
|
||
|
||
**业务目标**:
|
||
- 提供统一的流量查询 API
|
||
- 区分主套餐和加油包流量
|
||
- 自动汇总总计流量
|
||
- 仅显示当前有效套餐
|
||
|
||
---
|
||
|
||
## 业务规则
|
||
|
||
### 1. 流量汇总规则
|
||
|
||
```
|
||
总计流量 = 主套餐流量 + 所有生效中/已用完加油包流量
|
||
|
||
包含的套餐:
|
||
- status=1(生效中)
|
||
- status=2(已用完但未过期)
|
||
|
||
不包含的套餐:
|
||
- status=0(待生效)
|
||
- status=3(已过期)
|
||
- status=4(已失效)
|
||
```
|
||
|
||
### 2. 主套餐优先显示
|
||
|
||
- **规则**:如果有多个主套餐(理论上只有1个生效中),优先显示 status=1 的主套餐
|
||
- **待生效主套餐**:不在客户视图中显示
|
||
|
||
### 3. 加油包按优先级排序
|
||
|
||
- **排序规则**:按 priority ASC 排序(优先扣减的加油包排在前面)
|
||
- **失效加油包**:不在客户视图中显示
|
||
|
||
---
|
||
|
||
## ADDED Requirements
|
||
|
||
### Requirement: 提供客户视图流量查询 API
|
||
|
||
系统 SHALL 提供 GET /api/h5/packages/my-usage API,返回客户的套餐流量使用情况。
|
||
|
||
#### Scenario: 查询单个主套餐流量
|
||
- **GIVEN** 客户有1个主套餐(已用 8GB,总量 10GB),无加油包
|
||
- **WHEN** 客户调用 GET /api/h5/packages/my-usage
|
||
- **THEN** 系统返回:
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"data": {
|
||
"main_package": {
|
||
"package_id": 123,
|
||
"package_name": "月度套餐10GB",
|
||
"used_mb": 8192,
|
||
"total_mb": 10240,
|
||
"status": 1,
|
||
"status_text": "生效中",
|
||
"expires_at": "2026-02-28T23:59:59Z"
|
||
},
|
||
"addon_packages": [],
|
||
"total": {
|
||
"used_mb": 8192,
|
||
"total_mb": 10240
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Scenario: 查询主套餐和加油包流量
|
||
- **GIVEN** 客户有:
|
||
- 主套餐:已用 9GB,总量 10GB
|
||
- 加油包1(priority=1):已用 3GB,总量 5GB
|
||
- 加油包2(priority=2):已用 1GB,总量 3GB
|
||
- **WHEN** 客户调用 GET /api/h5/packages/my-usage
|
||
- **THEN** 系统返回 main_package, addon_packages(2个加油包,按 priority 排序), total: {used: 13GB, total: 18GB}
|
||
|
||
#### Scenario: 主套餐用完但加油包有剩余
|
||
- **GIVEN** 客户主套餐已用 10GB/总量 10GB(status=2),加油包已用 2GB/总量 5GB(status=1)
|
||
- **WHEN** 客户调用 API
|
||
- **THEN** 系统返回:
|
||
- main_package: status=2, status_text="已用完"
|
||
- addon_packages: status=1, status_text="生效中"
|
||
- total: {used: 12GB, total: 15GB}
|
||
|
||
### Requirement: 客户视图区分主套餐和加油包
|
||
|
||
系统 SHALL 在响应中明确区分主套餐(main_package)和加油包(addon_packages)的流量信息。
|
||
|
||
#### Scenario: 响应包含主套餐信息
|
||
- **WHEN** 客户查询流量使用情况
|
||
- **THEN** 响应的 main_package 字段包含:
|
||
- package_id, package_name
|
||
- used_mb, total_mb
|
||
- status, status_text
|
||
- expires_at, activated_at
|
||
|
||
#### Scenario: 响应包含加油包列表
|
||
- **GIVEN** 客户有3个加油包
|
||
- **WHEN** 客户查询
|
||
- **THEN** 响应的 addon_packages 字段为数组,按 priority 排序,每个元素包含:
|
||
- package_id, package_name
|
||
- used_mb, total_mb
|
||
- status, status_text
|
||
- expires_at, activated_at
|
||
- priority
|
||
|
||
### Requirement: 客户视图显示总计流量
|
||
|
||
系统 SHALL 在响应中提供 total 字段,汇总主套餐和所有加油包的流量。
|
||
|
||
#### Scenario: 总计流量计算正确
|
||
- **GIVEN** 主套餐 used=8GB/total=10GB,加油包1 used=2GB/total=5GB,加油包2 used=1GB/total=3GB
|
||
- **WHEN** 计算总计
|
||
- **THEN** total: {used_mb: 11GB, total_mb: 18GB}
|
||
|
||
#### Scenario: 已失效加油包不计入总计
|
||
- **GIVEN** 主套餐 used=8GB/total=10GB,加油包 status=4(已失效)used=2GB/total=5GB
|
||
- **WHEN** 计算总计
|
||
- **THEN** total: {used_mb: 8GB, total_mb: 10GB}(不包含已失效加油包)
|
||
|
||
#### Scenario: 已用完套餐计入总计
|
||
- **GIVEN** 主套餐 status=2(已用完)used=10GB/total=10GB,加油包 status=1 used=2GB/total=5GB
|
||
- **WHEN** 计算总计
|
||
- **THEN** total: {used_mb: 12GB, total_mb: 15GB}(已用完套餐仍计入)
|
||
|
||
### Requirement: 客户视图仅返回当前生效套餐
|
||
|
||
系统 SHALL 仅返回 status=1(生效中)或 status=2(已用完但未过期)的套餐信息。
|
||
|
||
#### Scenario: 不返回待生效套餐
|
||
- **GIVEN** 客户有1个生效中主套餐(status=1)和1个待生效主套餐(status=0)
|
||
- **WHEN** 客户查询
|
||
- **THEN** 响应仅包含生效中的主套餐,不包含待生效套餐
|
||
|
||
#### Scenario: 不返回已过期套餐
|
||
- **GIVEN** 客户的主套餐已过期(status=3)
|
||
- **WHEN** 客户查询
|
||
- **THEN** 响应 main_package=null,提示"无有效套餐"
|
||
|
||
#### Scenario: 不返回已失效加油包
|
||
- **GIVEN** 客户有生效中主套餐和1个已失效加油包(status=4)
|
||
- **WHEN** 客户查询
|
||
- **THEN** 响应 addon_packages 不包含已失效加油包
|
||
|
||
### Requirement: 客户视图性能要求
|
||
|
||
系统 SHALL 确保客户视图 API 响应时间 P95 < 200ms。
|
||
|
||
#### Scenario: 查询性能达标
|
||
- **GIVEN** 客户有1个主套餐和5个加油包
|
||
- **WHEN** 客户调用 API
|
||
- **THEN** API 响应时间 < 200ms(P95)
|
||
|
||
#### Scenario: 使用索引优化查询
|
||
- **GIVEN** 系统有索引 idx_carrier_status(carrier_id + status)
|
||
- **WHEN** 查询套餐时
|
||
- **THEN** 数据库使用索引,查询时间 < 50ms
|
||
|
||
---
|
||
|
||
## 边界条件
|
||
|
||
### 1. 无任何套餐
|
||
|
||
- **场景**:客户没有购买任何套餐
|
||
- **处理**:返回 main_package=null, addon_packages=[], total={used_mb:0, total_mb:0}
|
||
|
||
### 2. 主套餐过期但加油包未过期
|
||
|
||
- **场景**:主套餐过期,加油包有独立有效期且未过期
|
||
- **处理**:主套餐过期时,加油包被级联失效(status=4),不显示在客户视图
|
||
|
||
### 3. 并发查询
|
||
|
||
- **场景**:客户短时间内多次调用查询 API
|
||
- **处理**:使用只读事务,确保数据一致性
|
||
|
||
---
|
||
|
||
## 数据一致性保证
|
||
|
||
### 1. 只读事务
|
||
|
||
- **查询套餐**:使用只读事务,确保数据一致性
|
||
|
||
### 2. 索引优化
|
||
|
||
- **必需索引**:
|
||
- `idx_carrier_status`(carrier_id + status)
|
||
- `idx_package_type_priority`(package_type + priority)
|
||
|
||
---
|
||
|
||
## 性能指标
|
||
|
||
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|
||
|------|-------------|---------|--------|
|
||
| 客户视图查询 | < 200ms (P95) | 500 QPS | 单载体查询(1主套餐+5加油包) |
|
||
| 数据库查询 | < 50ms | 1000 QPS | 索引查询 |
|
||
|
||
---
|
||
|
||
## 错误码定义
|
||
|
||
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||
|--------|------------|---------|------|
|
||
| `NO_VALID_PACKAGE` | 404 | 无有效套餐 | 客户无任何生效中套餐 |
|
||
| `CARRIER_NOT_FOUND` | 404 | 载体不存在 | 载体ID不存在 |
|
||
|
||
---
|
||
|
||
## 数据迁移策略
|
||
|
||
**激进策略**(开发阶段,保证干净性):
|
||
|
||
### 1. ❌ 要删除的字段
|
||
|
||
无(新增 API,不涉及数据迁移)
|
||
|
||
### 2. ✅ 新增的字段
|
||
|
||
无(使用现有字段)
|
||
|
||
### 3. ❌ 要废弃的逻辑
|
||
|
||
- **废弃旧的客户端流量查询 API**:如果存在旧的流量查询接口,统一替换为新接口
|
||
|
||
### 4. ✅ 索引优化
|
||
|
||
```sql
|
||
-- 确保必需索引存在
|
||
CREATE INDEX IF NOT EXISTS idx_carrier_status
|
||
ON package_usage(carrier_id, status);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_package_type_priority
|
||
ON package_usage(package_type, priority);
|
||
```
|
||
|
||
---
|
||
|
||
## 测试场景矩阵
|
||
|
||
| 场景分类 | 测试用例 | 预期结果 |
|
||
|---------|---------|---------|
|
||
| **单个主套餐** | 查询单个主套餐流量 | 返回 main_package, addon_packages=[], total |
|
||
| **主套餐+加油包** | 查询主套餐和加油包 | 返回 main_package, addon_packages(按 priority 排序), total |
|
||
| **总计流量** | 总计流量计算正确 | total = 主套餐 + 所有加油包 |
|
||
| | 已失效加油包不计入总计 | 不包含 status=4 的加油包 |
|
||
| | 已用完套餐计入总计 | 包含 status=2 的套餐 |
|
||
| **筛选套餐** | 不返回待生效套餐 | 仅返回 status IN (1,2) |
|
||
| | 不返回已过期套餐 | main_package=null |
|
||
| | 不返回已失效加油包 | addon_packages 不含 status=4 |
|
||
| **性能** | 查询性能达标 | 响应时间 < 200ms (P95) |
|
||
| | 使用索引优化 | 数据库查询 < 50ms |
|
||
| **边界** | 无任何套餐 | main_package=null, addon_packages=[], total={0,0} |
|
||
| | 主套餐过期加油包未过期 | 加油包被级联失效,不显示 |
|
||
|
||
---
|
||
|
||
## 实现参考
|
||
|
||
### Handler: GetMyUsage
|
||
|
||
```go
|
||
// Handler: GetMyUsage
|
||
func (h *Handler) GetMyUsage(c *fiber.Ctx) error {
|
||
// 从上下文获取载体信息
|
||
carrierType := middleware.GetCarrierTypeFromContext(c.UserContext())
|
||
carrierID := middleware.GetCarrierIDFromContext(c.UserContext())
|
||
|
||
// 查询流量使用情况
|
||
usage, err := h.service.GetMyUsage(c.UserContext(), carrierType, carrierID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.Success(c, usage)
|
||
}
|
||
|
||
// Service 层:GetMyUsage
|
||
func (s *Service) GetMyUsage(ctx context.Context, carrierType string, carrierID uint) (*dto.MyUsageResponse, error) {
|
||
// 查询生效中或已用完的套餐
|
||
usages, err := s.store.ListActiveUsages(ctx, carrierType, carrierID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐失败")
|
||
}
|
||
|
||
// 分类套餐
|
||
var mainPackage *model.PackageUsage
|
||
var addonPackages []*model.PackageUsage
|
||
|
||
for _, usage := range usages {
|
||
if usage.PackageType == constants.PackageTypeFormal {
|
||
if mainPackage == nil || usage.Status == constants.PackageStatusActive {
|
||
mainPackage = usage // 优先选择生效中的主套餐
|
||
}
|
||
} else if usage.PackageType == constants.PackageTypeAddon {
|
||
addonPackages = append(addonPackages, usage)
|
||
}
|
||
}
|
||
|
||
// 按优先级排序加油包
|
||
sort.Slice(addonPackages, func(i, j int) bool {
|
||
return addonPackages[i].Priority < addonPackages[j].Priority
|
||
})
|
||
|
||
// 构造响应
|
||
resp := &dto.MyUsageResponse{
|
||
Total: &dto.TotalUsage{
|
||
UsedMB: 0,
|
||
TotalMB: 0,
|
||
},
|
||
}
|
||
|
||
// 主套餐
|
||
if mainPackage != nil {
|
||
resp.MainPackage = s.toPackageUsageVO(mainPackage)
|
||
resp.Total.UsedMB += mainPackage.DataUsageMB
|
||
resp.Total.TotalMB += mainPackage.TotalDataMB
|
||
}
|
||
|
||
// 加油包
|
||
for _, addon := range addonPackages {
|
||
resp.AddonPackages = append(resp.AddonPackages, s.toPackageUsageVO(addon))
|
||
resp.Total.UsedMB += addon.DataUsageMB
|
||
resp.Total.TotalMB += addon.TotalDataMB
|
||
}
|
||
|
||
return resp, nil
|
||
}
|
||
|
||
// Store 层:ListActiveUsages
|
||
func (s *Store) ListActiveUsages(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
|
||
var usages []*model.PackageUsage
|
||
err := s.db.WithContext(ctx).
|
||
Where("carrier_type = ? AND carrier_id = ? AND status IN (?, ?)",
|
||
carrierType, carrierID,
|
||
constants.PackageStatusActive,
|
||
constants.PackageStatusUsedUp).
|
||
Order("package_type ASC, priority ASC").
|
||
Find(&usages).Error
|
||
return usages, err
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
**本 Spec 完成**(简化版),包含:
|
||
- ✅ 业务背景和业务规则
|
||
- ✅ 详细场景(主套餐、加油包、总计流量)
|
||
- ✅ 边界条件
|
||
- ✅ 数据一致性保证和性能指标
|
||
- ✅ 错误码定义
|
||
- ✅ **激进的数据迁移策略**(索引优化)
|
||
- ✅ 测试场景矩阵和实现参考
|