# 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 完成**(简化版),包含: - ✅ 业务背景和业务规则 - ✅ 详细场景(主套餐、加油包、总计流量) - ✅ 边界条件 - ✅ 数据一致性保证和性能指标 - ✅ 错误码定义 - ✅ **激进的数据迁移策略**(索引优化) - ✅ 测试场景矩阵和实现参考