feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
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:
@@ -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_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 完成**(扩展版),包含:
|
||||
- ✅ 业务背景和业务规则
|
||||
- ✅ 详细场景(创建、更新、查询)
|
||||
- ✅ 边界条件和并发场景
|
||||
- ✅ 数据一致性保证和性能指标
|
||||
- ✅ 错误码定义
|
||||
- ✅ **激进的数据迁移策略**(明确标注 ❌ 删除和 ✅ 新增)
|
||||
- ✅ 测试场景矩阵和实现参考
|
||||
Reference in New Issue
Block a user