All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
**变更说明**: - 删除所有 *_test.go 文件(单元测试、集成测试、验收测试、流程测试) - 删除整个 tests/ 目录 - 更新 CLAUDE.md:用"测试禁令"章节替换所有测试要求 - 删除测试生成 Skill (openspec-generate-acceptance-tests) - 删除测试生成命令 (opsx:gen-tests) - 更新 tasks.md:删除所有测试相关任务 **新规范**: - ❌ 禁止编写任何形式的自动化测试 - ❌ 禁止创建 *_test.go 文件 - ❌ 禁止在任务中包含测试相关工作 - ✅ 仅当用户明确要求时才编写测试 **原因**: 业务系统的正确性通过人工验证和生产环境监控保证,测试代码维护成本高于价值。 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
11 KiB
11 KiB
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 系统返回:
{ "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. ✅ 索引优化
-- 确保必需索引存在
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
// 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 完成(简化版),包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(主套餐、加油包、总计流量)
- ✅ 边界条件
- ✅ 数据一致性保证和性能指标
- ✅ 错误码定义
- ✅ 激进的数据迁移策略(索引优化)
- ✅ 测试场景矩阵和实现参考