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

20 KiB
Raw Blame History

Spec Delta: 套餐管理能力扩展

业务背景

为什么需要扩展套餐管理字段

现状问题

  • 现有套餐管理缺少周期类型(自然月 vs 按天)配置
  • 流量重置周期(每日/每月/每年/不重置)无法配置
  • 实名激活机制无法按套餐级别控制
  • 旧字段duration无法区分自然月和按天套餐

业务目标

  • 在套餐创建/更新时支持新字段配置
  • 确保 calendar_type 与 duration_months/duration_days 的一致性
  • 支持 data_reset_cycle 的灵活配置
  • 支持 enable_realname_activation 的开关控制

业务规则

1. 周期类型与时长字段的关联规则

IF calendar_type = natural_month:
    THEN duration_months 必填duration_days 可选
ELSE IF calendar_type = by_day:
    THEN duration_days 必填duration_months 可选

验证规则:
- natural_month 套餐:必须提供 duration_months
- by_day 套餐:必须提供 duration_days

2. 流量重置周期的取值范围

data_reset_cycle ∈ {daily, monthly, yearly, none}

默认值规则:
- 主套餐:默认 monthly
- 加油包:默认 none

3. 实名激活开关规则

enable_realname_activation: boolean

- true套餐激活前必须实名认证
- false套餐激活不需要实名认证

默认值:
- 主套餐:默认 true
- 加油包:默认 false

MODIFIED Requirements

Requirement: 创建套餐

系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、周期类型calendar_type、流量重置周期data_reset_cycle、是否需要实名激活enable_realname_activation、流量配置、价格和建议价格。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。

Scenario: 成功创建自然月套餐

  • GIVEN 管理员提供套餐信息calendar_type=natural_monthduration_months=1
  • WHEN 提交创建请求
  • THEN 系统创建套餐,状态=1上架状态=2calendar_type=natural_month

Scenario: 成功创建按天套餐

  • GIVEN 管理员提供套餐信息calendar_type=by_dayduration_days=30
  • WHEN 提交创建请求
  • THEN 系统创建套餐calendar_type=by_dayduration_days=30

Scenario: 套餐编码重复

  • GIVEN 数据库中存在套餐编码为 "PKG001" 的套餐(未删除)
  • WHEN 管理员创建套餐,编码为 "PKG001"
  • THEN 系统返回错误 "套餐编码已存在"

Scenario: 关联不存在的套餐系列

  • GIVEN 管理员指定 series_id=999但系列不存在
  • WHEN 提交创建请求
  • THEN 系统返回错误 "套餐系列不存在"

Scenario: 缺少必填字段

  • GIVEN 管理员未提供套餐编码
  • WHEN 提交创建请求
  • THEN 系统返回参数验证错误 "套餐编码为必填项"

Scenario: 创建自然月套餐时必须提供 duration_months

  • GIVEN 管理员创建套餐calendar_type=natural_month但未提供 duration_months
  • WHEN 提交创建请求
  • THEN 系统返回错误 "自然月套餐必须指定 duration_months"

Scenario: 创建按天套餐时必须提供 duration_days

  • GIVEN 管理员创建套餐calendar_type=by_day但未提供 duration_days
  • WHEN 提交创建请求
  • THEN 系统返回错误 "按天套餐必须指定 duration_days"

Scenario: 默认 data_reset_cycle 为 monthly

  • GIVEN 管理员创建主套餐,未指定 data_reset_cycle
  • WHEN 提交创建请求
  • THEN 系统自动设置 data_reset_cycle=monthly

Scenario: 默认 enable_realname_activation 为 true

  • GIVEN 管理员创建主套餐,未指定 enable_realname_activation
  • WHEN 提交创建请求
  • THEN 系统自动设置 enable_realname_activation=true

Requirement: 更新套餐

系统 SHALL 允许管理员更新套餐的基本信息,包括周期类型、流量重置周期、实名激活配置等新增字段。套餐编码创建后 MUST NOT 允许修改。

Scenario: 成功更新套餐基本信息

  • GIVEN 管理员更新套餐名称和价格
  • WHEN 提交更新请求
  • THEN 系统更新套餐记录,返回更新后的详情

Scenario: 尝试修改套餐编码

  • GIVEN 管理员尝试修改套餐编码
  • WHEN 提交更新请求
  • THEN 系统忽略套餐编码字段,不进行修改

Scenario: 更新不存在的套餐

  • GIVEN 管理员更新套餐 ID=999但套餐不存在
  • WHEN 提交更新请求
  • THEN 系统返回 "套餐不存在" 错误

Scenario: 关联不存在的套餐系列

  • GIVEN 管理员将套餐的 series_id 改为 999但系列不存在
  • WHEN 提交更新请求
  • THEN 系统返回错误 "套餐系列不存在"

Scenario: 更新套餐周期类型(从自然月改为按天)

  • GIVEN 套餐当前 calendar_type=natural_monthduration_months=1
  • WHEN 管理员更新 calendar_type=by_dayduration_days=30
  • THEN 系统更新成功calendar_type=by_dayduration_days=30

Scenario: 更新套餐周期类型(从按天改为自然月)

  • GIVEN 套餐当前 calendar_type=by_dayduration_days=30
  • WHEN 管理员更新 calendar_type=natural_monthduration_months=1
  • THEN 系统更新成功calendar_type=natural_monthduration_months=1

Scenario: 更新周期类型但未提供对应时长字段

  • GIVEN 套餐当前 calendar_type=by_day
  • WHEN 管理员更新 calendar_type=natural_month但未提供 duration_months
  • THEN 系统返回错误 "自然月套餐必须指定 duration_months"

Scenario: 更新 data_reset_cycle

  • GIVEN 套餐当前 data_reset_cycle=monthly
  • WHEN 管理员更新 data_reset_cycle=daily
  • THEN 系统更新成功data_reset_cycle=daily

Scenario: 更新 enable_realname_activation

  • GIVEN 套餐当前 enable_realname_activation=true
  • WHEN 管理员更新 enable_realname_activation=false
  • THEN 系统更新成功enable_realname_activation=false

Requirement: 查询套餐详情

系统 SHALL 允许管理员查询单个套餐的详细信息,响应包含新增字段calendar_type, data_reset_cycle, enable_realname_activation

Scenario: 查询存在的套餐

  • GIVEN 数据库中存在套餐 ID=1
  • WHEN 管理员请求套餐详情
  • THEN 系统返回该套餐的完整信息,包含所有新增字段

Scenario: 查询不存在的套餐

  • GIVEN 管理员请求套餐 ID=999但套餐不存在
  • WHEN 提交查询请求
  • THEN 系统返回 "套餐不存在" 错误

Scenario: 响应包含周期类型信息

  • GIVEN 套餐 calendar_type=natural_monthduration_months=1
  • WHEN 管理员查询套餐详情
  • THEN 响应包含 calendar_type=natural_monthduration_months=1

Scenario: 响应包含流量重置周期信息

  • GIVEN 套餐 data_reset_cycle=monthly
  • WHEN 管理员查询套餐详情
  • THEN 响应包含 data_reset_cycle=monthly

Scenario: 响应包含实名激活配置

  • GIVEN 套餐 enable_realname_activation=true
  • WHEN 管理员查询套餐详情
  • THEN 响应包含 enable_realname_activation=true

边界条件

1. 套餐编码唯一性

  • 场景:套餐编码已存在(未删除)
  • 处理:返回错误 "套餐编码已存在"

2. 套餐系列不存在

  • 场景:创建/更新套餐时,指定的系列 ID 不存在
  • 处理:返回错误 "套餐系列不存在"

3. 周期类型与时长字段不匹配

  • 场景calendar_type=natural_month 但未提供 duration_months
  • 处理:返回错误 "自然月套餐必须指定 duration_months"

并发场景

1. 并发创建相同编码的套餐

  • 场景:两个管理员同时创建编码为 "PKG001" 的套餐
  • 处理数据库唯一索引code + deleted_at保证只有一个创建成功另一个返回错误

2. 并发更新套餐信息

  • 场景:两个管理员同时更新同一个套餐
  • 处理使用乐观锁updated_at后提交的更新成功前提交的更新被覆盖

数据一致性保证

1. 套餐编码唯一性

  • 机制数据库唯一索引code + deleted_at

2. 套餐系列外键校验

  • 机制:在创建/更新前,查询系列是否存在

3. 周期类型与时长字段一致性

  • 机制:在 Service 层校验 calendar_type 与 duration_months/duration_days 的匹配

性能指标

操作 目标响应时间 并发要求 数据量
创建套餐 < 100ms (P95) 50 QPS 单条插入
更新套餐 < 100ms (P95) 50 QPS 单条更新
查询套餐详情 < 50ms (P95) 500 QPS 单条查询
列表查询 < 200ms (P95) 200 QPS 分页查询(默认 20 条)

错误码定义

错误码 HTTP 状态码 错误消息 场景
PACKAGE_CODE_EXISTS 400 套餐编码已存在 创建套餐时编码重复
SERIES_NOT_FOUND 404 套餐系列不存在 创建/更新套餐时系列不存在
PACKAGE_NOT_FOUND 404 套餐不存在 查询/更新/删除不存在的套餐
INVALID_CALENDAR_TYPE 400 无效的周期类型 calendar_type 不在 {natural_month, by_day}
MISSING_DURATION_MONTHS 400 自然月套餐必须指定 duration_months 创建自然月套餐未提供 duration_months
MISSING_DURATION_DAYS 400 按天套餐必须指定 duration_days 创建按天套餐未提供 duration_days
INVALID_DATA_RESET_CYCLE 400 无效的流量重置周期 data_reset_cycle 不在 {daily, monthly, yearly, none}

数据迁移策略

激进策略(开发阶段,保证干净性):

1. 要删除的字段

-- 删除旧的 duration 字段(已被 duration_months/duration_days 替代)
ALTER TABLE package DROP COLUMN IF EXISTS duration;

-- 删除旧的 reset_interval, reset_day 字段(已被 data_reset_cycle 替代)
ALTER TABLE package DROP COLUMN IF EXISTS reset_interval;
ALTER TABLE package DROP COLUMN IF EXISTS reset_day;

2. 新增的字段

-- 新增 calendar_type 字段(必填)
ALTER TABLE package
ADD COLUMN calendar_type VARCHAR(20) NOT NULL DEFAULT 'by_day';

-- 新增 data_reset_cycle 字段(必填)
ALTER TABLE package
ADD COLUMN data_reset_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly';

-- 新增 enable_realname_activation 字段(必填)
ALTER TABLE package
ADD COLUMN enable_realname_activation BOOLEAN NOT NULL DEFAULT true;

-- 新增 duration_months 字段(可选,自然月套餐必填)
ALTER TABLE package
ADD COLUMN duration_months INT;

-- 新增 duration_days 字段(可选,按天套餐必填)
ALTER TABLE package
ADD COLUMN duration_days INT;

3. 历史数据转换

-- 将现有套餐统一设置为按天套餐
UPDATE package
SET calendar_type = 'by_day',
    duration_days = COALESCE(duration_months, 1) * 30
WHERE deleted_at IS NULL;

-- 将现有套餐设置默认流量重置周期
UPDATE package
SET data_reset_cycle = 'monthly'
WHERE deleted_at IS NULL;

-- 将现有套餐设置默认实名激活开关
UPDATE package
SET enable_realname_activation = true
WHERE deleted_at IS NULL;

4. 索引优化

-- 确保套餐编码唯一索引存在
CREATE UNIQUE INDEX IF NOT EXISTS idx_package_code
ON package(code)
WHERE deleted_at IS NULL;

-- 添加周期类型索引(用于按类型查询)
CREATE INDEX IF NOT EXISTS idx_package_calendar_type
ON package(calendar_type);

测试场景矩阵

场景分类 测试用例 预期结果
创建套餐 成功创建自然月套餐 calendar_type=natural_monthduration_months=1
成功创建按天套餐 calendar_type=by_dayduration_days=30
套餐编码重复 返回 "套餐编码已存在"
关联不存在的系列 返回 "套餐系列不存在"
缺少必填字段 返回参数验证错误
自然月套餐未提供 duration_months 返回 "自然月套餐必须指定 duration_months"
按天套餐未提供 duration_days 返回 "按天套餐必须指定 duration_days"
默认 data_reset_cycle data_reset_cycle=monthly
默认 enable_realname_activation enable_realname_activation=true
更新套餐 成功更新基本信息 套餐信息已更新
尝试修改套餐编码 编码不变
更新不存在的套餐 返回 "套餐不存在"
更新周期类型(从自然月改为按天) calendar_type=by_dayduration_days=30
更新周期类型但未提供对应时长 返回错误
更新 data_reset_cycle data_reset_cycle 已更新
更新 enable_realname_activation enable_realname_activation 已更新
查询套餐 查询存在的套餐 返回完整信息,包含新增字段
查询不存在的套餐 返回 "套餐不存在"
响应包含周期类型信息 包含 calendar_type, duration_months/duration_days
响应包含流量重置周期 包含 data_reset_cycle
响应包含实名激活配置 包含 enable_realname_activation
并发 并发创建相同编码套餐 只有一个成功
并发更新套餐 后提交的更新成功

实现参考

Handler: CreatePackage

// Handler: CreatePackage
func (h *Handler) CreatePackage(c *fiber.Ctx) error {
    var req dto.CreatePackageRequest
    if err := c.BodyParser(&req); err != nil {
        return errors.New(errors.CodeInvalidParam)
    }

    // 调用 Service 层
    pkg, err := h.service.CreatePackage(c.UserContext(), &req)
    if err != nil {
        return err
    }

    return response.Success(c, pkg)
}

// Service 层CreatePackage
func (s *Service) CreatePackage(ctx context.Context, req *dto.CreatePackageRequest) (*model.Package, error) {
    // 1. 校验套餐编码唯一性
    exists, err := s.store.ExistsByCode(ctx, req.Code)
    if err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐编码失败")
    }
    if exists {
        return nil, errors.New(errors.CodePackageCodeExists, "套餐编码已存在")
    }

    // 2. 校验套餐系列是否存在
    if req.SeriesID != nil {
        exists, err := s.seriesStore.ExistsByID(ctx, *req.SeriesID)
        if err != nil {
            return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐系列失败")
        }
        if !exists {
            return nil, errors.New(errors.CodeSeriesNotFound, "套餐系列不存在")
        }
    }

    // 3. 校验周期类型与时长字段一致性
    if err := s.validateCalendarType(req.CalendarType, req.DurationMonths, req.DurationDays); err != nil {
        return nil, err
    }

    // 4. 设置默认值
    if req.DataResetCycle == "" {
        req.DataResetCycle = constants.DataResetCycleMonthly
    }
    if req.EnableRealnameActivation == nil {
        defaultValue := true
        req.EnableRealnameActivation = &defaultValue
    }

    // 5. 创建套餐
    pkg := &model.Package{
        Code:                      req.Code,
        Name:                      req.Name,
        SeriesID:                  req.SeriesID,
        PackageType:               req.PackageType,
        CalendarType:              req.CalendarType,
        DurationMonths:            req.DurationMonths,
        DurationDays:              req.DurationDays,
        DataResetCycle:            req.DataResetCycle,
        EnableRealnameActivation:  *req.EnableRealnameActivation,
        TotalDataMB:               req.TotalDataMB,
        Price:                     req.Price,
        SuggestedPrice:            req.SuggestedPrice,
        Status:                    constants.PackageStatusEnabled,
        ListingStatus:             constants.ListingStatusOffShelf,
    }

    if err := s.store.Create(ctx, pkg); err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐失败")
    }

    return pkg, nil
}

// 校验周期类型与时长字段一致性
func (s *Service) validateCalendarType(calendarType string, durationMonths, durationDays *int) error {
    if calendarType == constants.CalendarTypeNaturalMonth {
        if durationMonths == nil || *durationMonths <= 0 {
            return errors.New(errors.CodeMissingDurationMonths, "自然月套餐必须指定 duration_months")
        }
    } else if calendarType == constants.CalendarTypeByDay {
        if durationDays == nil || *durationDays <= 0 {
            return errors.New(errors.CodeMissingDurationDays, "按天套餐必须指定 duration_days")
        }
    } else {
        return errors.New(errors.CodeInvalidCalendarType, "无效的周期类型")
    }
    return nil
}

// Store 层ExistsByCode
func (s *Store) ExistsByCode(ctx context.Context, code string) (bool, error) {
    var count int64
    err := s.db.WithContext(ctx).
        Model(&model.Package{}).
        Where("code = ? AND deleted_at IS NULL", code).
        Count(&count).Error
    return count > 0, err
}

Handler: UpdatePackage

// Handler: UpdatePackage
func (h *Handler) UpdatePackage(c *fiber.Ctx) error {
    id, err := c.ParamsInt("id")
    if err != nil {
        return errors.New(errors.CodeInvalidParam)
    }

    var req dto.UpdatePackageRequest
    if err := c.BodyParser(&req); err != nil {
        return errors.New(errors.CodeInvalidParam)
    }

    // 调用 Service 层
    pkg, err := h.service.UpdatePackage(c.UserContext(), uint(id), &req)
    if err != nil {
        return err
    }

    return response.Success(c, pkg)
}

// Service 层UpdatePackage
func (s *Service) UpdatePackage(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*model.Package, error) {
    // 1. 查询套餐是否存在
    pkg, err := s.store.GetByID(ctx, id)
    if err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐失败")
    }
    if pkg == nil {
        return nil, errors.New(errors.CodePackageNotFound, "套餐不存在")
    }

    // 2. 校验套餐系列是否存在
    if req.SeriesID != nil {
        exists, err := s.seriesStore.ExistsByID(ctx, *req.SeriesID)
        if err != nil {
            return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐系列失败")
        }
        if !exists {
            return nil, errors.New(errors.CodeSeriesNotFound, "套餐系列不存在")
        }
        pkg.SeriesID = req.SeriesID
    }

    // 3. 校验周期类型与时长字段一致性(如果更新了周期类型)
    if req.CalendarType != "" {
        if err := s.validateCalendarType(req.CalendarType, req.DurationMonths, req.DurationDays); err != nil {
            return nil, err
        }
        pkg.CalendarType = req.CalendarType
        if req.DurationMonths != nil {
            pkg.DurationMonths = req.DurationMonths
        }
        if req.DurationDays != nil {
            pkg.DurationDays = req.DurationDays
        }
    }

    // 4. 更新其他字段
    if req.Name != "" {
        pkg.Name = req.Name
    }
    if req.DataResetCycle != "" {
        pkg.DataResetCycle = req.DataResetCycle
    }
    if req.EnableRealnameActivation != nil {
        pkg.EnableRealnameActivation = *req.EnableRealnameActivation
    }
    if req.Price != nil {
        pkg.Price = *req.Price
    }
    if req.SuggestedPrice != nil {
        pkg.SuggestedPrice = *req.SuggestedPrice
    }

    // 5. 更新套餐
    if err := s.store.Update(ctx, pkg); err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐失败")
    }

    return pkg, nil
}

本 Spec Delta 完成(扩展版),包含:

  • 业务背景和业务规则
  • 详细场景(创建、更新、查询)
  • 边界条件和并发场景
  • 数据一致性保证和性能指标
  • 错误码定义
  • 激进的数据迁移策略(明确标注 删除和 新增)
  • 测试场景矩阵和实现参考