# 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_month,duration_months=1 - **WHEN** 提交创建请求 - **THEN** 系统创建套餐,状态=1,上架状态=2,calendar_type=natural_month #### Scenario: 成功创建按天套餐 - **GIVEN** 管理员提供套餐信息,calendar_type=by_day,duration_days=30 - **WHEN** 提交创建请求 - **THEN** 系统创建套餐,calendar_type=by_day,duration_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_month,duration_months=1 - **WHEN** 管理员更新 calendar_type=by_day,duration_days=30 - **THEN** 系统更新成功,calendar_type=by_day,duration_days=30 #### Scenario: 更新套餐周期类型(从按天改为自然月) - **GIVEN** 套餐当前 calendar_type=by_day,duration_days=30 - **WHEN** 管理员更新 calendar_type=natural_month,duration_months=1 - **THEN** 系统更新成功,calendar_type=natural_month,duration_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_month,duration_months=1 - **WHEN** 管理员查询套餐详情 - **THEN** 响应包含 calendar_type=natural_month,duration_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. ❌ 要删除的字段 ```sql -- 删除旧的 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. ✅ 新增的字段 ```sql -- 新增 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. ✅ 历史数据转换 ```sql -- 将现有套餐统一设置为按天套餐 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. ✅ 索引优化 ```sql -- 确保套餐编码唯一索引存在 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_month,duration_months=1 | | | 成功创建按天套餐 | calendar_type=by_day,duration_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_day,duration_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 ```go // 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 ```go // 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 完成**(扩展版),包含: - ✅ 业务背景和业务规则 - ✅ 详细场景(创建、更新、查询) - ✅ 边界条件和并发场景 - ✅ 数据一致性保证和性能指标 - ✅ 错误码定义 - ✅ **激进的数据迁移策略**(明确标注 ❌ 删除和 ✅ 新增) - ✅ 测试场景矩阵和实现参考