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>
20 KiB
20 KiB
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. ❌ 要删除的字段
-- 删除旧的 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_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
// 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 完成(扩展版),包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(创建、更新、查询)
- ✅ 边界条件和并发场景
- ✅ 数据一致性保证和性能指标
- ✅ 错误码定义
- ✅ 激进的数据迁移策略(明确标注 ❌ 删除和 ✅ 新增)
- ✅ 测试场景矩阵和实现参考