feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s

- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入
- 实现套餐流量重置服务(日/月/年周期重置)
- 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑
- 新增订单创建幂等性防重(Redis 业务键 + 分布式锁)
- 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求
- 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南)
- 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录
- 新增 queue types 抽象和 Redis 常量定义
This commit is contained in:
2026-02-12 14:24:15 +08:00
parent 655c9ce7a6
commit c665f32976
51 changed files with 7289 additions and 424 deletions

View File

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