Files
junhong_cmp_fiber/openspec/specs/package-usage-customer-view/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

368 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: 客户视图流量查询
## 业务背景
### 为什么需要客户视图流量查询
**现状问题**
- 客户无法清晰看到主套餐和加油包的分别使用情况
- 流量汇总不准确(包含已失效加油包)
- 客户端需要多次调用 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
- 加油包1priority=1已用 3GB总量 5GB
- 加油包2priority=2已用 1GB总量 3GB
- **WHEN** 客户调用 GET /api/h5/packages/my-usage
- **THEN** 系统返回 main_package, addon_packages2个加油包按 priority 排序), total: {used: 13GB, total: 18GB}
#### Scenario: 主套餐用完但加油包有剩余
- **GIVEN** 客户主套餐已用 10GB/总量 10GBstatus=2加油包已用 2GB/总量 5GBstatus=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 响应时间 < 200msP95
#### Scenario: 使用索引优化查询
- **GIVEN** 系统有索引 idx_carrier_statuscarrier_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 完成**(简化版),包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(主套餐、加油包、总计流量)
- ✅ 边界条件
- ✅ 数据一致性保证和性能指标
- ✅ 错误码定义
-**激进的数据迁移策略**(索引优化)
- ✅ 测试场景矩阵和实现参考