Files
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

11 KiB
Raw Blame History

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
    • 加油包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_statuscarrier_id + status
    • idx_package_type_prioritypackage_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 完成(简化版),包含:

  • 业务背景和业务规则
  • 详细场景(主套餐、加油包、总计流量)
  • 边界条件
  • 数据一致性保证和性能指标
  • 错误码定义
  • 激进的数据迁移策略(索引优化)
  • 测试场景矩阵和实现参考