feat: 实现套餐管理模块,包含套餐系列、双状态管理、废弃模型清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m24s

- 新增套餐系列管理 (CRUD + 状态切换)
- 新增套餐管理 (CRUD + 启用/禁用 + 上架/下架双状态)
- 清理 8 个废弃分佣模型及对应数据库表
- Package 模型新增建议成本价、建议售价、上架状态字段
- 完整的 Store/Service/Handler 三层实现
- 包含单元测试和集成测试
- 归档 add-package-module change
- 新增多个 OpenSpec changes (订单支付、店铺套餐分配、一次性分佣、卡设备系列绑定)
This commit is contained in:
2026-01-27 19:55:47 +08:00
parent 30a0717316
commit 79c061b6fa
70 changed files with 7554 additions and 244 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-27

View File

@@ -0,0 +1,123 @@
## Context
Phase 2 完成了代理套餐分配机制,但卡和设备还没有关联到具体的套餐系列。本期在 IotCard 和 Device 模型上新增字段,记录其所属的套餐系列分配,为后续的套餐购买和佣金计算做准备。
**关键业务规则**
- 卡/设备关联后才能购买该系列下的套餐
- 设备关联后,其绑定的所有卡共享该套餐系列(设备级套餐)
- 每张卡/设备只能触发一次一次性佣金
## Goals / Non-Goals
**Goals:**
- 在 IotCard 模型新增套餐系列关联和佣金状态字段
- 在 Device 模型新增相同字段
- 提供批量设置卡/设备套餐系列的 API
- 验证关联的系列必须是当前店铺被分配的
**Non-Goals:**
- 不实现订单支付Phase 4
- 不实现佣金计算Phase 5
- 不自动同步设备和卡的关联(手动设置)
## Decisions
### 1. 新增字段设计
**决策**:在 IotCard 和 Device 模型各新增 3 个字段
```go
// IotCard 新增字段
SeriesAllocationID uint `gorm:"column:series_allocation_id;index;comment:套餐系列分配ID" json:"series_allocation_id"`
FirstCommissionPaid bool `gorm:"column:first_commission_paid;default:false;comment:一次性佣金是否已发放" json:"first_commission_paid"`
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分)" json:"accumulated_recharge"`
// Device 新增字段(相同)
SeriesAllocationID uint `gorm:"column:series_allocation_id;index;comment:套餐系列分配ID" json:"series_allocation_id"`
FirstCommissionPaid bool `gorm:"column:first_commission_paid;default:false;comment:一次性佣金是否已发放" json:"first_commission_paid"`
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分)" json:"accumulated_recharge"`
```
**理由**
- `series_allocation_id`:关联到 ShopSeriesAllocation决定可购买的套餐
- `first_commission_paid`:标记一次性佣金状态,防止重复发放
- `accumulated_recharge`:累计充值金额,用于累计充值触发条件
### 2. 设备与卡的关系
**决策**:设备和卡独立设置套餐系列
```
场景 1单卡销售
- IotCard.series_allocation_id 有值
- 购买套餐时使用卡的 series_allocation_id
场景 2设备销售整机出货
- Device.series_allocation_id 有值
- 设备下的卡可以不设置 series_allocation_id
- 购买套餐时优先使用 Device.series_allocation_id
```
**理由**
- 单卡和设备是两种不同的销售模式
- 设备级套餐购买时,所有卡共享流量
- 佣金按设备计算,不按卡数倍增
### 3. 批量设置 API 设计
**决策**:使用 PATCH 方法批量更新
```
PATCH /api/admin/iot-cards/series-bindng
Body: { "iccids": ["xxx", "yyy"], "series_allocation_id": 123 }
PATCH /api/admin/devices/series-bindng
Body: { "device_ids": [1, 2, 3], "series_allocation_id": 123 }
```
**理由**
- PATCH 语义合适(部分更新)
- 支持批量操作提高效率
- 通过 ICCID/设备 ID 定位资源
### 4. 权限验证
**决策**:只能关联当前店铺被分配的套餐系列
验证逻辑:
1. 获取卡/设备的 shop_id
2. 检查 series_allocation_id 对应的分配是否属于该店铺
3. 检查分配状态是否启用
**理由**
- 防止关联未被分配的系列
- 确保数据一致性
## Risks / Trade-offs
### 风险 1批量操作性能
**风险**:大批量设置时可能超时
**缓解**
- 限制单次批量数量(如最多 500 条)
- 使用批量更新 SQL 而非循环单条更新
### 风险 2设备和卡关联不一致
**风险**:设备设置了系列但卡没设置,或反过来
**缓解**
- 购买套餐时明确优先级:设备级 > 卡级
- 查询接口明确返回实际使用的系列来源
## Open Questions
1. **是否需要清除关联功能?**
- 当前设计:可以将 series_allocation_id 设为 0 清除关联
- 待确认:清除后是否影响已购买的套餐?
2. **设备和卡的 accumulated_recharge 如何同步?**
- 当前设计:设备级购买时更新 Device.accumulated_recharge
- 单卡购买时更新 IotCard.accumulated_recharge
- 待确认:是否需要双向同步?

View File

@@ -0,0 +1,58 @@
## Why
Phase 2 完成了代理套餐分配,但卡和设备还没有关联到具体的套餐系列分配。需要在卡/设备上记录其所属的套餐系列分配,以便后续购买套餐时验证权限、计算佣金。同时需要记录一次性佣金状态和累计充值金额,为 Phase 5 的佣金计算做准备。
## What Changes
**IotCard 模型调整:**
- 新增 `series_allocation_id`:关联的套餐系列分配 ID
- 新增 `first_commission_paid`一次性佣金是否已发放bool
- 新增 `accumulated_recharge`:累计充值金额(分)
**Device 模型调整:**
- 新增 `series_allocation_id`:关联的套餐系列分配 ID
- 新增 `first_commission_paid`一次性佣金是否已发放bool
- 新增 `accumulated_recharge`:累计充值金额(分)
**新增 API**
- 批量设置卡的套餐系列分配
- 批量设置设备的套餐系列分配
- 查询卡/设备的套餐系列分配信息
**业务规则:**
- 卡/设备只能关联当前所属店铺被分配的套餐系列
- 设备关联后,其绑定的所有卡共享该套餐系列
- 关联后可购买该系列下的套餐
## Capabilities
### New Capabilities
- `card-series-bindng`: 卡套餐系列关联 - 为 IoT 卡设置套餐系列分配,记录佣金状态
- `device-series-bindng`: 设备套餐系列关联 - 为设备设置套餐系列分配,设备下所有卡共享
### Modified Capabilities
<!-- 无 -->
## Impact
**代码影响:**
- `internal/model/iot_card.go` - 新增 3 个字段
- `internal/model/device.go` - 新增 3 个字段
- `migrations/` - 修改 tb_iot_card 和 tb_device 表
- `internal/handler/admin/` - 扩展卡/设备 Handler
- `internal/service/` - 扩展卡/设备 Service
- `internal/model/dto/` - 新增请求 DTO
**API 影响:**
- 新增 `PATCH /api/admin/iot-cards/series-bindng` 批量设置卡系列
- 新增 `PATCH /api/admin/devices/series-bindng` 批量设置设备系列
**数据库影响:**
- 修改表:`tb_iot_card` 新增 3 个字段
- 修改表:`tb_device` 新增 3 个字段
**依赖关系:**
- 依赖 Phase 2add-shop-package-allocation完成
- Phase 4订单与支付依赖本期

View File

@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: 批量设置卡的套餐系列
系统 SHALL 允许代理批量为 IoT 卡设置套餐系列分配。只能设置当前店铺被分配且启用的套餐系列。
#### Scenario: 成功批量设置
- **WHEN** 代理提交多个 ICCID 和一个有效的 series_allocation_id
- **THEN** 系统更新这些卡的 series_allocation_id 字段
#### Scenario: 系列未分配给店铺
- **WHEN** 代理尝试设置一个未分配给卡所属店铺的系列
- **THEN** 系统返回错误 "该套餐系列未分配给此店铺"
#### Scenario: 系列分配已禁用
- **WHEN** 代理尝试设置一个已禁用的系列分配
- **THEN** 系统返回错误 "该套餐系列分配已禁用"
#### Scenario: ICCID 不存在
- **WHEN** 提交的 ICCID 中有不存在的卡
- **THEN** 系统返回错误,列出不存在的 ICCID
#### Scenario: 卡不属于当前店铺
- **WHEN** 代理尝试设置不属于自己店铺的卡
- **THEN** 系统返回错误 "部分卡不属于您的店铺"
---
### Requirement: 清除卡的套餐系列关联
系统 SHALL 允许代理清除卡的套餐系列关联(将 series_allocation_id 设为 0
#### Scenario: 清除单卡关联
- **WHEN** 代理将卡的 series_allocation_id 设为 0
- **THEN** 系统清除该卡的套餐系列关联
#### Scenario: 批量清除关联
- **WHEN** 代理批量提交 ICCID 列表series_allocation_id 为 0
- **THEN** 系统清除这些卡的套餐系列关联
---
### Requirement: 查询卡的套餐系列信息
系统 SHALL 在卡详情和列表中返回套餐系列关联信息。
#### Scenario: 卡详情包含系列信息
- **WHEN** 查询卡详情
- **THEN** 响应包含 series_allocation_id、关联的系列名称、佣金状态
#### Scenario: 卡列表支持按系列筛选
- **WHEN** 代理按 series_allocation_id 筛选卡列表
- **THEN** 系统只返回关联该系列的卡
---
### Requirement: IotCard 模型新增字段
系统 MUST 在 IotCard 模型中新增以下字段:
- `series_allocation_id`:套餐系列分配 ID
- `first_commission_paid`:一次性佣金是否已发放(默认 false
- `accumulated_recharge`:累计充值金额(默认 0
#### Scenario: 新卡默认值
- **WHEN** 创建新的 IoT 卡
- **THEN** series_allocation_id 为空first_commission_paid 为 falseaccumulated_recharge 为 0
#### Scenario: 字段在响应中可见
- **WHEN** 查询卡信息
- **THEN** 响应包含这三个新字段

View File

@@ -0,0 +1,84 @@
## ADDED Requirements
### Requirement: 批量设置设备的套餐系列
系统 SHALL 允许代理批量为设备设置套餐系列分配。只能设置当前店铺被分配且启用的套餐系列。
#### Scenario: 成功批量设置
- **WHEN** 代理提交多个设备 ID 和一个有效的 series_allocation_id
- **THEN** 系统更新这些设备的 series_allocation_id 字段
#### Scenario: 系列未分配给店铺
- **WHEN** 代理尝试设置一个未分配给设备所属店铺的系列
- **THEN** 系统返回错误 "该套餐系列未分配给此店铺"
#### Scenario: 系列分配已禁用
- **WHEN** 代理尝试设置一个已禁用的系列分配
- **THEN** 系统返回错误 "该套餐系列分配已禁用"
#### Scenario: 设备不存在
- **WHEN** 提交的设备 ID 中有不存在的设备
- **THEN** 系统返回错误,列出不存在的设备 ID
#### Scenario: 设备不属于当前店铺
- **WHEN** 代理尝试设置不属于自己店铺的设备
- **THEN** 系统返回错误 "部分设备不属于您的店铺"
---
### Requirement: 清除设备的套餐系列关联
系统 SHALL 允许代理清除设备的套餐系列关联。
#### Scenario: 清除单设备关联
- **WHEN** 代理将设备的 series_allocation_id 设为 0
- **THEN** 系统清除该设备的套餐系列关联
#### Scenario: 批量清除关联
- **WHEN** 代理批量提交设备 ID 列表series_allocation_id 为 0
- **THEN** 系统清除这些设备的套餐系列关联
---
### Requirement: 查询设备的套餐系列信息
系统 SHALL 在设备详情和列表中返回套餐系列关联信息。
#### Scenario: 设备详情包含系列信息
- **WHEN** 查询设备详情
- **THEN** 响应包含 series_allocation_id、关联的系列名称、佣金状态
#### Scenario: 设备列表支持按系列筛选
- **WHEN** 代理按 series_allocation_id 筛选设备列表
- **THEN** 系统只返回关联该系列的设备
---
### Requirement: Device 模型新增字段
系统 MUST 在 Device 模型中新增以下字段:
- `series_allocation_id`:套餐系列分配 ID
- `first_commission_paid`:一次性佣金是否已发放(默认 false
- `accumulated_recharge`:累计充值金额(默认 0
#### Scenario: 新设备默认值
- **WHEN** 创建新设备
- **THEN** series_allocation_id 为空first_commission_paid 为 falseaccumulated_recharge 为 0
#### Scenario: 字段在响应中可见
- **WHEN** 查询设备信息
- **THEN** 响应包含这三个新字段
---
### Requirement: 设备级套餐购买优先级
设备购买套餐时 MUST 使用 Device.series_allocation_id 确定可购买的套餐系列,而非设备下单卡的 series_allocation_id。
#### Scenario: 设备有系列关联
- **WHEN** 设备有 series_allocation_id且其下的卡也有各自的 series_allocation_id
- **THEN** 设备级套餐购买使用设备的 series_allocation_id
#### Scenario: 设备无系列关联
- **WHEN** 设备的 series_allocation_id 为空
- **THEN** 该设备无法购买设备级套餐

View File

@@ -0,0 +1,85 @@
## 1. IotCard 模型调整
- [ ] 1.1 在 `internal/model/iot_card.go` 中新增 `series_allocation_id` 字段uint, index, 可空)
- [ ] 1.2 新增 `first_commission_paid` 字段bool, 默认 false
- [ ] 1.3 新增 `accumulated_recharge` 字段bigint, 默认 0
## 2. Device 模型调整
- [ ] 2.1 在 `internal/model/device.go` 中新增 `series_allocation_id` 字段uint, index, 可空)
- [ ] 2.2 新增 `first_commission_paid` 字段bool, 默认 false
- [ ] 2.3 新增 `accumulated_recharge` 字段bigint, 默认 0
## 3. 数据库迁移
- [ ] 3.1 创建迁移文件,为 tb_iot_card 添加 3 个新字段
- [ ] 3.2 为 tb_device 添加 3 个新字段
- [ ] 3.3 为 series_allocation_id 添加索引
- [ ] 3.4 本地执行迁移验证
## 4. DTO 更新
- [ ] 4.1 更新 IotCard 相关 DTO新增 series_allocation_id、first_commission_paid、accumulated_recharge 字段
- [ ] 4.2 更新 Device 相关 DTO新增相同字段
- [ ] 4.3 创建 BatchSetSeriesBindngRequesticcids/device_ids + series_allocation_id
- [ ] 4.4 创建 BatchSetSeriesBindngResponse成功数、失败列表
## 5. IotCard Store 更新
- [ ] 5.1 在 IotCardStore 中添加 BatchUpdateSeriesAllocation 方法
- [ ] 5.2 添加 ListBySeriesAllocationID 方法(按系列筛选)
- [ ] 5.3 更新 List 方法支持 series_allocation_id 筛选
## 6. Device Store 更新
- [ ] 6.1 在 DeviceStore 中添加 BatchUpdateSeriesAllocation 方法
- [ ] 6.2 添加 ListBySeriesAllocationID 方法
- [ ] 6.3 更新 List 方法支持 series_allocation_id 筛选
## 7. IotCard Service 更新
- [ ] 7.1 在 IotCardService 中添加 BatchSetSeriesBindng 方法(验证权限、验证系列分配)
- [ ] 7.2 添加 ValidateSeriesAllocation 辅助方法(检查系列是否分配给店铺)
## 8. Device Service 更新
- [ ] 8.1 在 DeviceService 中添加 BatchSetSeriesBindng 方法
- [ ] 8.2 添加 ValidateSeriesAllocation 辅助方法
## 9. IotCard Handler 更新
- [ ] 9.1 在 IotCardHandler 中添加 BatchSetSeriesBindng 接口PATCH /api/admin/iot-cards/series-bindng
- [ ] 9.2 更新 List 接口支持 series_allocation_id 筛选参数
- [ ] 9.3 更新 Get 接口响应包含系列关联信息
## 10. Device Handler 更新
- [ ] 10.1 在 DeviceHandler 中添加 BatchSetSeriesBindng 接口PATCH /api/admin/devices/series-bindng
- [ ] 10.2 更新 List 接口支持 series_allocation_id 筛选参数
- [ ] 10.3 更新 Get 接口响应包含系列关联信息
## 11. 路由注册
- [ ] 11.1 注册 `PATCH /api/admin/iot-cards/series-bindng` 路由
- [ ] 11.2 注册 `PATCH /api/admin/devices/series-bindng` 路由
## 12. 文档生成器更新
- [ ] 12.1 更新 docs.go 和 gendocs/main.go如有新 Handler
- [ ] 12.2 执行文档生成验证
## 13. 测试
- [ ] 13.1 IotCardStore 批量更新方法单元测试
- [ ] 13.2 DeviceStore 批量更新方法单元测试
- [ ] 13.3 IotCardService BatchSetSeriesBindng 单元测试(覆盖权限验证)
- [ ] 13.4 DeviceService BatchSetSeriesBindng 单元测试
- [ ] 13.5 卡系列关联 API 集成测试
- [ ] 13.6 设备系列关联 API 集成测试
- [ ] 13.7 执行 `go test ./...` 确认通过
## 14. 最终验证
- [ ] 14.1 执行 `go build ./...` 确认编译通过
- [ ] 14.2 启动服务,手动测试批量设置功能
- [ ] 14.3 验证列表筛选功能正常

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-27

View File

@@ -0,0 +1,252 @@
## Context
Phase 4 完成了订单和支付流程,现在需要实现佣金计算。当终端用户购买套餐支付成功后,系统自动计算各级代理的佣金并入账。
**佣金来源**
1. **成本价差收入**:每笔订单必触发,售价 - 成本价 = 代理收入
2. **一次性佣金**:满足触发条件时发放一次,金额从梯度配置获取
**当前 CommissionRecord 模型过于复杂**(包含冻结/解冻字段),需要简化。
## Goals / Non-Goals
**Goals:**
- 简化 CommissionRecord 模型
- 实现成本价差收入计算(每笔订单)
- 实现一次性佣金触发(充值阈值)
- 佣金直接入账到店铺钱包
- 提供佣金记录查询和统计
**Non-Goals:**
- 不实现冻结/解冻机制
- 不实现长期佣金(号卡专用)
- 不实现梯度佣金统计(本期只做配置,统计后续优化)
- 不实现佣金审批流程
## Decisions
### 1. CommissionRecord 模型简化
**决策**:删除冻结相关字段,新增来源和关联字段
```go
// 简化后的 CommissionRecord
type CommissionRecord struct {
gorm.Model
BaseModel
ShopID uint // 店铺ID佣金归属
OrderID uint // 关联订单ID
IotCardID uint // 关联卡ID可空
DeviceID uint // 关联设备ID可空
CommissionSource string // 佣金来源: cost_diff-成本价差 one_time-一次性佣金 tier_bonus-梯度奖励
Amount int64 // 佣金金额(分)
BalanceAfter int64 // 入账后钱包余额(分)
Status int // 状态: 1-已入账 2-已失效
ReleasedAt *time.Time // 入账时间
Remark string // 备注
}
```
**删除字段**
- `agent_id`(改用 shop_id
- `rule_id`(不再关联复杂规则)
- `commission_type`(改用 commission_source
- `unfrozen_at`、冻结相关状态
### 2. 佣金计算流程
**决策**:订单支付成功后异步计算
```
订单支付成功
发送异步任务 (Asynq)
佣金计算任务执行:
1. 获取订单信息
2. 遍历代理层级(从销售店铺到顶级)
3. 每级计算成本价差收入
4. 检查一次性佣金触发条件
5. 创建 CommissionRecord
6. 更新店铺钱包余额
7. 更新订单 commission_status
```
### 3. 成本价差收入计算
**决策**:各级代理按自己的成本价差计算
**计算规则**
- 终端销售代理:收入 = 售价 - 自己的成本价
- 中间层级代理:收入 = 下级的成本价 - 自己的成本价
```go
func CalculateCostDiffCommission(order *Order) []CommissionRecord {
var records []CommissionRecord
// 获取销售店铺(终端销售的代理)
sellerShop := GetShop(order.SellerShopID)
sellerCostPrice := GetCostPrice(sellerShop.ID, order.PackageID)
// 终端销售代理的收入 = 售价 - 成本价
sellerProfit := order.TotalAmount - sellerCostPrice
if sellerProfit > 0 {
records = append(records, CommissionRecord{
ShopID: sellerShop.ID,
OrderID: order.ID,
CommissionSource: "cost_diff",
Amount: sellerProfit,
})
}
// 遍历上级代理链
childCostPrice := sellerCostPrice
currentShop := GetShop(sellerShop.ParentID)
for currentShop != nil {
// 获取当前店铺的成本价
myCostPrice := GetCostPrice(currentShop.ID, order.PackageID)
// 收入 = 下级成本价 - 自己成本价
profit := childCostPrice - myCostPrice
if profit > 0 {
records = append(records, CommissionRecord{
ShopID: currentShop.ID,
OrderID: order.ID,
CommissionSource: "cost_diff",
Amount: profit,
})
}
// 移动到上级
childCostPrice = myCostPrice
currentShop = GetShop(currentShop.ParentID)
}
return records
}
```
### 4. 一次性佣金触发
**决策**:两种触发类型,每张卡/设备只触发一次
```go
// 触发类型 A一次性充值 ≥ 阈值
func CheckOneTimeRecharge(order *Order, threshold int64) bool {
return order.TotalAmount >= threshold
}
// 触发类型 B累计充值 ≥ 阈值
func CheckAccumulatedRecharge(card *IotCard, threshold int64) bool {
return card.AccumulatedRecharge >= threshold
}
// 检查并发放一次性佣金
func TriggerOneTimeCommission(order *Order, card *IotCard) {
if card.FirstCommissionPaid {
return // 已发放过
}
// 获取配置的触发条件和金额
tier := GetCommissionTier(card.SeriesAllocationID)
// 检查触发条件
triggered := false
switch tier.TriggerType {
case "one_time_recharge":
triggered = CheckOneTimeRecharge(order, tier.ThresholdValue)
case "accumulated_recharge":
triggered = CheckAccumulatedRecharge(card, tier.ThresholdValue)
}
if triggered {
// 发放佣金
CreateCommissionRecord(...)
// 标记已发放
card.FirstCommissionPaid = true
UpdateCard(card)
}
}
```
### 5. 钱包入账
**决策**:直接入账,无冻结期
```go
func CreditCommission(record *CommissionRecord) error {
return Transaction(func(tx *gorm.DB) error {
// 1. 获取店铺钱包
wallet := GetWallet("shop", record.ShopID)
// 2. 增加余额
wallet.Balance += record.Amount
UpdateWallet(wallet)
// 3. 记录余额
record.BalanceAfter = wallet.Balance
record.Status = 1 // 已入账
record.ReleasedAt = time.Now()
UpdateCommissionRecord(record)
// 4. 创建钱包交易记录
CreateWalletTransaction(...)
return nil
})
}
```
### 6. API 设计
```
# 佣金记录查询
GET /api/admin/commission-records 佣金记录列表
GET /api/admin/commission-records/:id 佣金记录详情
# 佣金统计
GET /api/admin/commission-stats 佣金统计(总收入、各来源占比)
GET /api/admin/commission-stats/daily 每日佣金统计
```
## Risks / Trade-offs
### 风险 1异步计算失败
**风险**:佣金计算任务失败导致佣金未发放
**缓解**
- Asynq 自动重试机制
- 记录任务执行日志
- 提供手动触发补偿接口
### 风险 2并发更新钱包余额
**风险**:多笔佣金同时入账导致余额计算错误
**缓解**
- 使用数据库事务
- 钱包更新使用乐观锁或悲观锁
### 风险 3代理层级变更
**风险**:订单支付后代理层级变更,佣金计算基于哪个时间点?
**缓解**
- 佣金计算基于订单支付时的代理关系
- 订单中可记录销售店铺ID快照
## Open Questions
1. **梯度佣金何时统计?**
- 当前设计:本期只做配置,不做实际统计
- 待确认:是否需要定时任务统计并发放梯度奖励?
2. **累计充值是否包含当前订单?**
- 当前设计:先更新累计充值,再检查触发条件
- 待确认:是否正确?
3. **一次性佣金发放给谁?**
- 当前设计:发放给卡/设备的直接归属店铺
- 待确认:是否需要多级分佣?

View File

@@ -0,0 +1,77 @@
## Why
Phase 4 完成了订单与支付流程,现在需要实现一次性佣金计算。当终端用户购买套餐时,各级代理根据成本价差获得收入,并根据配置的触发条件(一次性充值阈值/累计充值阈值)发放一次性佣金。
## What Changes
**CommissionRecord 模型简化:**
- 移除冻结/解冻相关字段unfreeze_days, unfrozen_at 等)
- 移除 rule_id不再关联复杂规则
- 移除 agent_id改用 shop_id佣金归属店铺而非个人账号
- 保留shop_id, order_id, amount, status, released_at, balance_after
- 新增commission_source成本价差/一次性佣金/梯度佣金)
- 新增iot_card_id/device_id关联的卡/设备)
- 新增remark备注
**佣金计算逻辑:**
1. **成本价差收入**(每笔订单必触发)
- 终端销售代理:售价 - 自己的成本价 = 收入
- 中间层级代理:下级的成本价 - 自己的成本价 = 收入
- 各级代理按自己的成本价差计算,确保每级都有利润
2. **一次性佣金**(满足条件触发一次)
- 触发类型 A一次性充值 ≥ 阈值
- 触发类型 B累计充值 ≥ 阈值
- 每张卡/设备只触发一次
- 佣金金额从 ShopSeriesCommissionTier 获取(支持梯度)
3. **多级分佣**
- 订单支付成功后,遍历代理层级
- 每级代理计算成本价差收入
- 检查一次性佣金触发条件
**新增 API**
- 佣金记录列表查询(按店铺/时间/来源筛选)
- 佣金统计(总收入、各来源占比)
- 手动触发佣金计算(补偿机制)
**业务规则:**
- 佣金直接入账到店铺钱包,无冻结期
- 一次性佣金只发放一次,通过 card.first_commission_paid 标记
- 累计充值记录在 card.accumulated_recharge
- 梯度佣金根据配置的时间范围统计销量/销售额
## Capabilities
### New Capabilities
- `commission-calculation`: 佣金计算 - 订单支付后自动计算各级代理的成本价差收入
- `one-time-commission-trigger`: 一次性佣金触发 - 根据充值阈值触发一次性佣金发放
- `commission-record-query`: 佣金记录查询 - 查询佣金明细和统计数据
### Modified Capabilities
<!-- 无CommissionRecord 的修改在 proposal 中说明,不需要单独 spec -->
## Impact
**代码影响:**
- `internal/model/commission.go` - 简化 CommissionRecord 模型
- `migrations/` - 修改 tb_commission_record 表结构
- `internal/handler/admin/` - 新增/修改佣金查询 Handler
- `internal/service/` - 新增佣金计算 Service
- `internal/store/postgres/` - 修改 CommissionRecordStore
- `internal/task/` - 佣金计算异步任务
**API 影响:**
- 修改 `/api/admin/commission-records/*` 佣金记录查询
- 新增 `/api/admin/commission-stats` 佣金统计
**数据库影响:**
- 修改表:`tb_commission_record`(简化字段、新增字段)
**依赖关系:**
- 依赖 Phase 4add-order-payment完成
- 依赖 Phase 2 的 ShopSeriesCommissionTier 梯度配置
- 依赖 Phase 3 的卡/设备佣金状态字段

View File

@@ -0,0 +1,73 @@
## ADDED Requirements
### Requirement: 订单支付后触发佣金计算
系统 SHALL 在订单支付成功后自动触发佣金计算。计算通过异步任务执行。
#### Scenario: 支付成功触发计算
- **WHEN** 订单支付状态变为已支付
- **THEN** 系统发送佣金计算异步任务
#### Scenario: 重复支付不重复计算
- **WHEN** 订单已计算过佣金commission_status=2
- **THEN** 系统不重复触发计算
---
### Requirement: 成本价差收入计算
系统 SHALL 为代理链上的每一级代理计算成本价差收入。终端销售代理收入 = 售价 - 成本价;中间层级代理收入 = 下级成本价 - 自己成本价。
#### Scenario: 单级代理
- **WHEN** 一级代理销售套餐,售价 100 元,成本价 80 元
- **THEN** 一级代理获得 20 元100 - 80成本价差收入
#### Scenario: 多级代理
- **WHEN** 三级代理销售套餐,售价 100 元,各级成本价为:平台 50 → 一级 60 → 二级 70 → 三级 80
- **THEN** 三级获得 20 元100 - 80二级获得 10 元80 - 70一级获得 10 元70 - 60平台获得 10 元60 - 50
#### Scenario: 成本价相同
- **WHEN** 某级代理成本价等于下级成本价
- **THEN** 该级代理成本价差收入为 0不创建佣金记录
---
### Requirement: 佣金直接入账
成本价差收入 SHALL 直接入账到店铺钱包,无冻结期。
#### Scenario: 佣金入账
- **WHEN** 计算出代理的成本价差收入
- **THEN** 系统直接增加店铺钱包余额,创建佣金记录和钱包交易记录
#### Scenario: 记录入账后余额
- **WHEN** 佣金入账
- **THEN** CommissionRecord.balance_after 记录入账后的钱包余额
---
### Requirement: 更新累计充值金额
订单支付成功后系统 SHALL 更新卡/设备的累计充值金额。
#### Scenario: 单卡订单更新累计充值
- **WHEN** 单卡订单支付成功,金额 100 元
- **THEN** IotCard.accumulated_recharge 增加 10000 分
#### Scenario: 设备订单更新累计充值
- **WHEN** 设备订单支付成功,金额 300 元
- **THEN** Device.accumulated_recharge 增加 30000 分
---
### Requirement: CommissionRecord 模型简化
系统 MUST 简化 CommissionRecord 模型,移除冻结相关字段。
#### Scenario: 新佣金记录字段
- **WHEN** 创建佣金记录
- **THEN** 包含shop_id, order_id, iot_card_id, device_id, commission_source, amount, balance_after, status, released_at, remark
#### Scenario: 佣金来源类型
- **WHEN** 创建佣金记录
- **THEN** commission_source 为以下之一cost_diff成本价差、one_time一次性佣金、tier_bonus梯度奖励

View File

@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: 查询佣金记录列表
系统 SHALL 提供佣金记录列表查询,支持按店铺、佣金来源、时间范围、状态筛选。
#### Scenario: 代理查询自己店铺的佣金
- **WHEN** 代理查询佣金记录列表
- **THEN** 系统返回该店铺的所有佣金记录
#### Scenario: 按佣金来源筛选
- **WHEN** 指定 commission_source 为 cost_diff
- **THEN** 系统只返回成本价差类型的佣金记录
#### Scenario: 按时间范围筛选
- **WHEN** 指定开始时间和结束时间
- **THEN** 系统只返回该时间范围内的佣金记录
#### Scenario: 响应包含关联信息
- **WHEN** 查询佣金记录列表
- **THEN** 每条记录包含:订单号、卡/设备信息、套餐名称
---
### Requirement: 查询佣金记录详情
系统 SHALL 允许查询单条佣金记录的详细信息。
#### Scenario: 查询佣金详情
- **WHEN** 代理查询指定佣金记录详情
- **THEN** 系统返回完整的佣金信息和关联的订单、卡/设备信息
#### Scenario: 查询他人佣金
- **WHEN** 代理尝试查询其他店铺的佣金记录
- **THEN** 系统返回 "记录不存在" 错误
---
### Requirement: 佣金统计
系统 SHALL 提供佣金统计功能,包含总收入和各来源占比。
#### Scenario: 查询总收入
- **WHEN** 代理查询佣金统计
- **THEN** 系统返回总收入金额(所有已入账佣金之和)
#### Scenario: 各来源占比
- **WHEN** 代理查询佣金统计
- **THEN** 系统返回各佣金来源的金额和占比cost_diff、one_time、tier_bonus
#### Scenario: 按时间范围统计
- **WHEN** 指定时间范围查询统计
- **THEN** 系统只统计该时间范围内的佣金
---
### Requirement: 每日佣金统计
系统 SHALL 提供每日佣金统计查询。
#### Scenario: 查询每日统计
- **WHEN** 代理查询指定日期范围的每日统计
- **THEN** 系统返回每天的佣金总额和笔数
#### Scenario: 默认最近30天
- **WHEN** 代理查询每日统计不指定日期范围
- **THEN** 系统返回最近 30 天的数据

View File

@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: 一次性充值触发佣金
系统 SHALL 支持"一次性充值"触发条件:当单笔订单金额 ≥ 配置阈值时触发一次性佣金。
#### Scenario: 达到一次性充值阈值
- **WHEN** 订单金额 500 元,配置阈值 300 元,该卡未发放过一次性佣金
- **THEN** 系统发放一次性佣金,标记卡的 first_commission_paid 为 true
#### Scenario: 未达到阈值
- **WHEN** 订单金额 200 元,配置阈值 300 元
- **THEN** 系统不发放一次性佣金
#### Scenario: 已发放过一次性佣金
- **WHEN** 订单金额 500 元,但卡的 first_commission_paid 已为 true
- **THEN** 系统不重复发放一次性佣金
---
### Requirement: 累计充值触发佣金
系统 SHALL 支持"累计充值"触发条件:当卡/设备的累计充值金额 ≥ 配置阈值时触发一次性佣金。
#### Scenario: 累计达到阈值
- **WHEN** 卡之前累计充值 200 元,本次充值 150 元,配置阈值 300 元
- **THEN** 累计 350 元 ≥ 300 元,系统发放一次性佣金
#### Scenario: 累计未达到阈值
- **WHEN** 卡之前累计充值 100 元,本次充值 100 元,配置阈值 300 元
- **THEN** 累计 200 元 < 300 元,系统不发放一次性佣金
---
### Requirement: 一次性佣金只发放一次
每张卡/设备的一次性佣金 SHALL 只发放一次,通过 first_commission_paid 字段控制。
#### Scenario: 首次触发
- **WHEN** 首次满足触发条件
- **THEN** 发放佣金,设置 first_commission_paid = true
#### Scenario: 再次满足条件
- **WHEN** 再次满足触发条件但 first_commission_paid 已为 true
- **THEN** 不发放佣金
---
### Requirement: 一次性佣金配置获取
一次性佣金的触发条件和金额 SHALL 从 ShopSeriesAllocation 配置获取。
#### Scenario: 获取触发条件和金额
- **WHEN** 触发一次性佣金检查
- **THEN** 系统从卡关联的 ShopSeriesAllocation 获取 one_time_commission_trigger触发类型、one_time_commission_threshold阈值、one_time_commission_amount金额
#### Scenario: 无一次性佣金配置
- **WHEN** 卡关联的系列分配未配置一次性佣金one_time_commission_amount = 0
- **THEN** 不发放一次性佣金
---
### Requirement: 一次性佣金发放对象
一次性佣金 SHALL 发放给卡/设备的直接归属店铺。
#### Scenario: 发放给归属店铺
- **WHEN** 卡归属店铺 A触发一次性佣金
- **THEN** 佣金入账到店铺 A 的钱包

View File

@@ -0,0 +1,99 @@
## 1. CommissionRecord 模型简化
- [ ] 1.1 修改 `internal/model/commission.go`,简化 CommissionRecord 结构
- [ ] 1.2 删除冻结相关字段unfrozen_at 等)
- [ ] 1.3 删除 rule_id、agent_id 字段
- [ ] 1.4 新增 commission_source 字段varchar: cost_diff, one_time, tier_bonus
- [ ] 1.5 新增 iot_card_id、device_id 字段
- [ ] 1.6 新增 remark 字段
## 2. 数据库迁移
- [ ] 2.1 创建迁移文件,修改 tb_commission_record 表结构
- [ ] 2.2 删除废弃字段
- [ ] 2.3 添加新字段
- [ ] 2.4 添加索引shop_id, order_id, commission_source, iot_card_id, device_id
- [ ] 2.5 本地执行迁移验证
## 3. DTO 更新
- [ ] 3.1 更新 `internal/model/dto/commission.go`,调整 CommissionRecordResponse
- [ ] 3.2 定义 CommissionRecordListRequestshop_id, commission_source, start_time, end_time, status
- [ ] 3.3 定义 CommissionStatsResponsetotal_amount, cost_diff_amount, one_time_amount, tier_bonus_amount
- [ ] 3.4 定义 DailyCommissionStatsResponse
## 4. CommissionRecord Store 更新
- [ ] 4.1 更新 `internal/store/postgres/commission_record_store.go`,适配新模型
- [ ] 4.2 更新 Create 方法
- [ ] 4.3 更新 List 方法支持新筛选条件
- [ ] 4.4 实现 GetStats 方法(统计总收入和各来源占比)
- [ ] 4.5 实现 GetDailyStats 方法(每日统计)
## 5. 佣金计算 Service
- [ ] 5.1 创建 `internal/service/commission_calculation/service.go`
- [ ] 5.2 实现 CalculateCommission 主方法(协调整体计算流程)
- [ ] 5.3 实现 CalculateCostDiffCommission 方法(遍历代理层级计算成本价差)
- [ ] 5.4 实现 CheckAndTriggerOneTimeCommission 方法(检查一次性佣金触发条件)
- [ ] 5.5 实现 CreditCommission 方法(佣金入账到钱包)
- [ ] 5.6 实现 UpdateAccumulatedRecharge 方法(更新累计充值金额)
## 6. 异步任务
- [ ] 6.1 创建 `internal/task/commission_calculation.go`,定义佣金计算任务类型
- [ ] 6.2 实现任务处理函数 HandleCommissionCalculation
- [ ] 6.3 在 OrderService.WalletPay 中添加任务发送逻辑
- [ ] 6.4 在支付回调处理中添加任务发送逻辑
- [ ] 6.5 在 Worker 中注册任务处理器
## 7. 佣金查询 Service
- [ ] 7.1 更新 `internal/service/my_commission/service.go`,适配新模型
- [ ] 7.2 实现 List 方法
- [ ] 7.3 实现 Get 方法
- [ ] 7.4 实现 GetStats 方法
- [ ] 7.5 实现 GetDailyStats 方法
## 8. Handler 更新
- [ ] 8.1 更新 `internal/handler/admin/my_commission.go`,适配新接口
- [ ] 8.2 实现 List 接口
- [ ] 8.3 实现 Get 接口
- [ ] 8.4 实现 GetStats 接口
- [ ] 8.5 实现 GetDailyStats 接口
## 9. Bootstrap 注册
- [ ] 9.1 在 services.go 中注册 CommissionCalculationService
- [ ] 9.2 确认 MyCommissionService 注册正确
## 10. 路由更新
- [ ] 10.1 确认 `/api/admin/my-commission/records` 路由
- [ ] 10.2 添加 `/api/admin/my-commission/stats` 路由
- [ ] 10.3 添加 `/api/admin/my-commission/daily-stats` 路由
## 11. 文档生成器更新
- [ ] 11.1 更新 docs.go 和 gendocs/main.go
- [ ] 11.2 执行文档生成验证
## 12. 测试
- [ ] 12.1 CommissionRecordStore 单元测试
- [ ] 12.2 CommissionCalculationService 单元测试(覆盖成本价差计算)
- [ ] 12.3 一次性佣金触发逻辑测试(覆盖各种触发条件)
- [ ] 12.4 佣金入账事务测试
- [ ] 12.5 异步任务测试
- [ ] 12.6 佣金统计 API 集成测试
- [ ] 12.7 执行 `go test ./...` 确认通过
## 13. 最终验证
- [ ] 13.1 执行 `go build ./...` 确认编译通过
- [ ] 13.2 启动服务,创建订单并支付
- [ ] 13.3 验证佣金记录正确创建
- [ ] 13.4 验证钱包余额正确增加
- [ ] 13.5 验证一次性佣金触发逻辑
- [ ] 13.6 验证佣金统计数据正确

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-27

View File

@@ -0,0 +1,237 @@
## Context
Phase 3 完成了卡/设备的套餐系列关联,现在需要实现订单和支付流程。核心是"强充"机制:用户必须通过购买套餐来充值,不能直接给钱包充值。这确保每笔资金流入都有对应的套餐购买记录。
**三类买家**
1. 个人客户:通过 H5/小程序购买,使用卡/设备钱包或第三方支付
2. 代理商:通过后台购买,使用店铺钱包
3. 企业客户:后台直接分配套餐,不走订单流程(本期不做)
## Goals / Non-Goals
**Goals:**
- 设计订单和订单明细模型
- 实现套餐购买订单创建流程
- 实现钱包支付和第三方支付回调
- 验证购买权限(卡/设备的套餐系列关联)
- 套餐生效后更新流量额度
**Non-Goals:**
- 不实现企业客户的套餐分配(后台直接操作)
- 不实现第三方支付发起(仅处理回调)
- 不实现佣金计算Phase 5
- 不实现退款流程
## Decisions
### 1. 订单模型设计
**决策**Order + OrderItem 两级结构
```go
// Order 订单模型
type Order struct {
gorm.Model
BaseModel
OrderNo string // 订单号(唯一)
OrderType string // 订单类型: single_card-单卡购买 device-设备购买
BuyerType string // 买家类型: personal-个人客户 agent-代理商
BuyerID uint // 买家ID个人客户ID或店铺ID
IotCardID uint // IoT卡ID单卡购买时
DeviceID uint // 设备ID设备购买时
TotalAmount int64 // 订单总金额(分)
PaymentMethod string // 支付方式: wallet-钱包 wechat-微信 alipay-支付宝
PaymentStatus int // 支付状态: 1-待支付 2-已支付 3-已取消 4-已退款
PaidAt *time.Time // 支付时间
CommissionStatus int // 佣金状态: 1-待计算 2-已计算
}
// OrderItem 订单明细模型
type OrderItem struct {
gorm.Model
BaseModel
OrderID uint // 订单ID
PackageID uint // 套餐ID
PackageName string // 套餐名称(快照)
Quantity int // 数量通常为1
UnitPrice int64 // 单价(分)
Amount int64 // 小计(分)
}
```
**理由**
- 支持一个订单购买多个套餐(虽然初期可能只买一个)
- 快照套餐名称,避免套餐修改影响历史订单
- 佣金状态用于 Phase 5 的异步佣金计算
### 2. 订单号生成规则
**决策**:时间戳 + 随机数
```
格式ORD{YYYYMMDDHHMMSS}{6位随机数}
示例ORD20260127143052123456
```
**理由**
- 可读性好,包含时间信息
- 随机数避免并发冲突
- 长度固定,便于存储和展示
### 3. 购买价格确定
**决策**:使用 Package.suggested_retail_price 作为统一售价
```
个人客户购买:支付金额 = Package.suggested_retail_price
代理为店铺购买:支付金额 = 代理的成本价(用于囤货/测试)
```
**理由**
- 简化首期实现,所有终端用户统一售价
- 代理的利润 = suggested_retail_price - 成本价
- 后续如需支持代理自定义售价,可扩展 ShopPackageAllocation 增加 retail_price 字段
**非首期功能**
- 代理自定义售价
- 促销折扣价
---
### 4. 购买权限验证
**决策**:多层验证
```go
func ValidatePurchase(card/device, packageID) error {
// 1. 获取卡/设备的 series_allocation_id
allocationID := card.SeriesAllocationID
if allocationID == 0 {
return "该卡未关联套餐系列"
}
// 2. 获取套餐信息
pkg := GetPackage(packageID)
// 3. 验证套餐属于该系列
allocation := GetAllocation(allocationID)
if pkg.SeriesID != allocation.SeriesID {
return "该套餐不在可购买范围内"
}
// 4. 验证套餐状态
if pkg.Status != 1 || pkg.ShelfStatus != 1 {
return "该套餐已下架"
}
return nil
}
```
### 5. 支付流程
**决策**:同步钱包支付 + 异步第三方支付
```
钱包支付流程:
1. 创建订单(待支付)
2. 检查钱包余额
3. 扣减钱包余额(事务)
4. 更新订单状态(已支付)
5. 套餐生效
6. 触发佣金计算(异步)
第三方支付流程:
1. 创建订单(待支付)
2. 返回订单信息,前端发起支付
3. 支付回调更新订单状态
4. 套餐生效
5. 触发佣金计算(异步)
```
### 6. 套餐生效逻辑
**决策**:创建 PackageUsage 记录
```go
func ActivatePackage(order *Order) {
for _, item := range order.Items {
pkg := GetPackage(item.PackageID)
usage := &PackageUsage{
OrderID: order.ID,
PackageID: item.PackageID,
UsageType: order.OrderType, // single_card 或 device
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
DataLimitMB: pkg.DataAmountMB,
ActivatedAt: time.Now(),
ExpiresAt: time.Now().AddDate(0, pkg.DurationMonths, 0),
Status: 1, // 生效中
}
CreatePackageUsage(usage)
}
}
```
### 7. API 设计
```
# 订单管理(后台)
POST /api/admin/orders 代理创建订单
GET /api/admin/orders 订单列表
GET /api/admin/orders/:id 订单详情
POST /api/admin/orders/:id/cancel 取消订单
# 订单操作H5/个人客户)
POST /api/h5/orders 个人客户创建订单
GET /api/h5/orders 我的订单列表
GET /api/h5/orders/:id 订单详情
POST /api/h5/orders/:id/pay 钱包支付
# 支付回调
POST /api/callback/wechat-pay 微信支付回调
POST /api/callback/alipay 支付宝回调
```
## Risks / Trade-offs
### 风险 1并发支付
**风险**:同一订单被重复支付
**缓解**
- 支付前检查订单状态
- 使用数据库乐观锁或 Redis 分布式锁
- 支付回调幂等处理
### 风险 2套餐生效失败
**风险**:支付成功但套餐生效失败
**缓解**
- 使用事务保证支付和套餐生效原子性
- 失败时自动退款或人工处理
- 记录详细日志便于排查
### 风险 3价格不一致
**风险**:下单时和支付时套餐价格变化
**缓解**
- 订单中存储下单时的价格快照
- 支付时使用订单金额,不重新查询套餐价格
## Open Questions
1. **订单超时取消?**
- 当前设计:不自动取消
- 待确认:是否需要定时任务取消超时未支付订单?
2. **部分支付?**
- 当前设计:不支持
- 待确认:是否需要支持钱包余额不足时组合支付?
3. **代理为终端用户购买?**
- 当前设计:代理只能为自己店铺购买
- 待确认:是否需要代理帮终端用户购买的场景?

View File

@@ -0,0 +1,70 @@
## Why
Phase 3 完成了卡/设备的套餐系列关联,现在需要实现订单和支付流程。核心是"强充"机制:用户不能直接给钱包充值,必须通过购买套餐来充值。这样每笔充值都有对应的套餐购买记录,便于佣金计算和业务追踪。
## What Changes
**新增模型:**
- `Order`:订单模型,记录套餐购买信息
- `OrderItem`:订单明细(支持一个订单购买多个套餐)
**Order 核心字段:**
- 订单号、订单类型(单卡购买/设备购买)
- 买家信息(个人客户/代理店铺)
- 关联的卡/设备 ID
- 支付金额、支付状态、支付方式
- 佣金计算状态
**强充业务流程:**
1. 用户选择套餐,创建订单
2. 用户支付(微信/支付宝/钱包余额)
3. 支付成功后,套餐生效,流量额度增加
4. 触发佣金计算Phase 5
**新增 API**
- 创建套餐购买订单
- 查询订单列表/详情
- 订单支付(钱包支付)
- 支付回调处理
- 取消订单
**业务规则:**
- 只能购买卡/设备关联的套餐系列下的套餐
- 只能购买已上架且启用的套餐
- 设备购买时,套餐分配给设备下所有卡(流量共享)
- 订单金额 = 套餐零售价(代理设置的售价)
## Capabilities
### New Capabilities
- `order-management`: 订单管理 - 创建/查询/取消套餐购买订单
- `order-payment`: 订单支付 - 钱包支付、第三方支付回调处理
- `package-purchase-validation`: 套餐购买验证 - 验证卡/设备是否有权购买指定套餐
### Modified Capabilities
<!-- 无 -->
## Impact
**代码影响:**
- `internal/model/` - 新增 order.goOrder, OrderItem
- `migrations/` - 创建 tb_order, tb_order_item 表
- `internal/handler/` - 新增订单 Handleradmin + app/h5
- `internal/service/` - 新增订单 Service
- `internal/store/postgres/` - 新增订单 Store
- `internal/model/dto/` - 新增订单相关 DTO
**API 影响:**
- 新增 `/api/admin/orders/*` 后台订单管理
- 新增 `/api/h5/orders/*` H5 端订单操作
- 新增 `/api/app/orders/*` 个人客户订单操作
**数据库影响:**
- 新增表:`tb_order`, `tb_order_item`
**依赖关系:**
- 依赖 Phase 3add-card-device-series-bindng完成
- Phase 5一次性佣金依赖本期
- 依赖现有 Wallet 模型

View File

@@ -0,0 +1,85 @@
## ADDED Requirements
### Requirement: 创建套餐购买订单
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限。
#### Scenario: 个人客户创建单卡订单
- **WHEN** 个人客户为自己的卡创建订单,选择一个套餐
- **THEN** 系统创建订单,状态为待支付,返回订单信息
#### Scenario: 个人客户创建设备订单
- **WHEN** 个人客户为自己的设备创建订单
- **THEN** 系统创建订单,订单类型为设备购买
#### Scenario: 代理创建订单
- **WHEN** 代理为店铺关联的卡/设备创建订单
- **THEN** 系统创建订单买家类型为代理商买家ID为店铺ID
#### Scenario: 套餐不在可购买范围
- **WHEN** 买家尝试购买不在关联系列下的套餐
- **THEN** 系统返回错误 "该套餐不在可购买范围内"
#### Scenario: 套餐已下架
- **WHEN** 买家尝试购买已下架的套餐
- **THEN** 系统返回错误 "该套餐已下架"
---
### Requirement: 查询订单列表
系统 SHALL 提供订单列表查询,支持按支付状态、订单类型、时间范围筛选。
#### Scenario: 个人客户查询自己的订单
- **WHEN** 个人客户查询订单列表
- **THEN** 系统只返回该客户的订单
#### Scenario: 代理查询店铺订单
- **WHEN** 代理查询订单列表
- **THEN** 系统返回该店铺及下级店铺的订单
#### Scenario: 按支付状态筛选
- **WHEN** 指定支付状态筛选
- **THEN** 系统只返回匹配状态的订单
---
### Requirement: 查询订单详情
系统 SHALL 允许买家查询订单详情,包含订单明细。
#### Scenario: 查询订单详情
- **WHEN** 买家查询指定订单详情
- **THEN** 系统返回订单信息和订单明细列表
#### Scenario: 查询他人订单
- **WHEN** 买家尝试查询不属于自己的订单
- **THEN** 系统返回 "订单不存在" 错误
---
### Requirement: 取消订单
系统 SHALL 允许买家取消未支付的订单。
#### Scenario: 取消待支付订单
- **WHEN** 买家取消一个待支付的订单
- **THEN** 系统更新订单状态为已取消
#### Scenario: 取消已支付订单
- **WHEN** 买家尝试取消已支付的订单
- **THEN** 系统返回错误 "已支付订单无法取消"
---
### Requirement: 订单号生成
系统生成的订单号 MUST 全局唯一,格式为 ORD{YYYYMMDDHHMMSS}{6位随机数}。
#### Scenario: 订单号格式
- **WHEN** 创建新订单
- **THEN** 订单号格式为 ORD + 14位时间戳 + 6位随机数
#### Scenario: 订单号唯一
- **WHEN** 并发创建多个订单
- **THEN** 每个订单的订单号都唯一

View File

@@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: 钱包支付
系统 SHALL 支持使用钱包余额支付订单。支付成功后 MUST 扣减钱包余额并激活套餐。
#### Scenario: 钱包余额充足
- **WHEN** 买家使用钱包支付,余额充足
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,创建套餐使用记录
#### Scenario: 钱包余额不足
- **WHEN** 买家使用钱包支付,余额不足
- **THEN** 系统返回错误 "钱包余额不足"
#### Scenario: 订单已支付
- **WHEN** 买家尝试支付已支付的订单
- **THEN** 系统返回错误 "订单已支付"
#### Scenario: 订单已取消
- **WHEN** 买家尝试支付已取消的订单
- **THEN** 系统返回错误 "订单已取消"
---
### Requirement: 第三方支付回调
系统 SHALL 处理微信支付和支付宝的支付回调。回调处理 MUST 幂等。
#### Scenario: 微信支付成功回调
- **WHEN** 收到微信支付成功回调
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
#### Scenario: 支付宝成功回调
- **WHEN** 收到支付宝支付成功回调
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
#### Scenario: 重复回调
- **WHEN** 收到已处理订单的重复回调
- **THEN** 系统返回成功响应,不重复处理
#### Scenario: 签名验证失败
- **WHEN** 回调签名验证失败
- **THEN** 系统拒绝处理,返回失败响应
---
### Requirement: 套餐激活
支付成功后系统 MUST 激活套餐,创建 PackageUsage 记录。
#### Scenario: 单卡套餐激活
- **WHEN** 单卡订单支付成功
- **THEN** 系统创建 PackageUsageusage_type 为 single_card关联 iot_card_id
#### Scenario: 设备套餐激活
- **WHEN** 设备订单支付成功
- **THEN** 系统创建 PackageUsageusage_type 为 device关联 device_id
#### Scenario: 套餐有效期计算
- **WHEN** 套餐激活
- **THEN** 有效期 = 激活时间 + 套餐时长(月)
---
### Requirement: 支付事务保证
钱包支付 MUST 在事务中完成:余额扣减、订单状态更新、套餐激活。任一步骤失败则全部回滚。
#### Scenario: 事务成功
- **WHEN** 所有步骤成功
- **THEN** 事务提交,支付完成
#### Scenario: 余额扣减后套餐激活失败
- **WHEN** 余额扣减成功但套餐激活失败
- **THEN** 事务回滚,余额恢复,订单状态不变

View File

@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: 验证卡/设备的套餐购买权限
创建订单前系统 MUST 验证卡/设备是否有权购买指定套餐。
#### Scenario: 卡有套餐系列关联
- **WHEN** 卡的 series_allocation_id 有值,且套餐属于该系列
- **THEN** 验证通过
#### Scenario: 卡无套餐系列关联
- **WHEN** 卡的 series_allocation_id 为空
- **THEN** 验证失败,返回 "该卡未关联套餐系列"
#### Scenario: 套餐不属于关联系列
- **WHEN** 套餐的 series_id 与卡关联的分配系列不匹配
- **THEN** 验证失败,返回 "该套餐不在可购买范围内"
#### Scenario: 系列分配已禁用
- **WHEN** 卡关联的系列分配状态为禁用
- **THEN** 验证失败,返回 "套餐系列已禁用"
---
### Requirement: 验证套餐状态
创建订单前系统 MUST 验证套餐处于可购买状态。
#### Scenario: 套餐启用且上架
- **WHEN** 套餐 status=1 且 shelf_status=1
- **THEN** 验证通过
#### Scenario: 套餐已禁用
- **WHEN** 套餐 status=2
- **THEN** 验证失败,返回 "套餐已禁用"
#### Scenario: 套餐已下架
- **WHEN** 套餐 shelf_status=2
- **THEN** 验证失败,返回 "套餐已下架"
---
### Requirement: 获取购买价格
系统 MUST 根据买家身份返回正确的购买价格。
#### Scenario: 个人客户购买
- **WHEN** 个人客户购买套餐
- **THEN** 使用 Package.suggested_retail_price 作为支付金额
#### Scenario: 代理为店铺购买
- **WHEN** 代理为自己店铺购买套餐(囤货/测试)
- **THEN** 使用代理的成本价作为支付金额
---
### Requirement: 设备购买时的卡验证
设备购买套餐时 MUST 使用设备的 series_allocation_id 验证,不使用设备下单卡的关联。
#### Scenario: 设备有系列关联
- **WHEN** 设备的 series_allocation_id 有值
- **THEN** 使用设备的关联验证购买权限
#### Scenario: 设备无系列关联
- **WHEN** 设备的 series_allocation_id 为空
- **THEN** 验证失败,返回 "该设备未关联套餐系列"

View File

@@ -0,0 +1,105 @@
## 1. 新增模型
- [ ] 1.1 创建 `internal/model/order.go`,定义 Order 模型order_no, order_type, buyer_type, buyer_id, iot_card_id, device_id, total_amount, payment_method, payment_status, paid_at, commission_status
- [ ] 1.2 定义 OrderItem 模型order_id, package_id, package_name, quantity, unit_price, amount
## 2. 数据库迁移
- [ ] 2.1 创建迁移文件,创建 tb_order 表
- [ ] 2.2 创建 tb_order_item 表
- [ ] 2.3 添加索引order_no 唯一索引, buyer_type+buyer_id, payment_status, iot_card_id, device_id
- [ ] 2.4 本地执行迁移验证
## 3. 订单 DTO
- [ ] 3.1 创建 `internal/model/dto/order.go`,定义 CreateOrderRequestorder_type, iot_card_id/device_id, package_ids
- [ ] 3.2 定义 OrderListRequestpayment_status, order_type, start_time, end_time, page, page_size
- [ ] 3.3 定义 PayOrderRequestpayment_method
- [ ] 3.4 定义 OrderResponse包含订单信息和明细列表
- [ ] 3.5 定义 OrderItemResponse
## 4. 订单 Store
- [ ] 4.1 创建 `internal/store/postgres/order_store.go`,实现 Create 方法(事务创建订单和明细)
- [ ] 4.2 实现 GetByID 方法(含明细)
- [ ] 4.3 实现 GetByOrderNo 方法
- [ ] 4.4 实现 Update 方法
- [ ] 4.5 实现 List 方法(支持分页和筛选)
- [ ] 4.6 实现 UpdatePaymentStatus 方法
- [ ] 4.7 实现 GenerateOrderNo 方法ORD + 时间戳 + 随机数)
## 5. 订单明细 Store
- [ ] 5.1 创建 `internal/store/postgres/order_item_store.go`,实现 BatchCreate 方法
- [ ] 5.2 实现 ListByOrderID 方法
## 6. 购买验证 Service
- [ ] 6.1 创建 `internal/service/purchase_validation/service.go`,实现 ValidateCardPurchase 方法
- [ ] 6.2 实现 ValidateDevicePurchase 方法
- [ ] 6.3 实现 ValidatePackageStatus 方法
- [ ] 6.4 实现 GetPurchasePrice 方法(根据买家身份返回价格)
## 7. 订单 Service
- [ ] 7.1 创建 `internal/service/order/service.go`,实现 Create 方法(验证权限、创建订单和明细)
- [ ] 7.2 实现 Get 方法
- [ ] 7.3 实现 List 方法
- [ ] 7.4 实现 Cancel 方法(验证状态、更新为已取消)
- [ ] 7.5 实现 WalletPay 方法(事务:扣减余额、更新状态、激活套餐)
- [ ] 7.6 实现 HandlePaymentCallback 方法(验证签名、幂等处理、激活套餐)
- [ ] 7.7 实现 ActivatePackage 辅助方法(创建 PackageUsage 记录)
## 8. 订单 Handler后台
- [ ] 8.1 创建 `internal/handler/admin/order.go`,实现 Create 接口
- [ ] 8.2 实现 Get 接口
- [ ] 8.3 实现 List 接口
- [ ] 8.4 实现 Cancel 接口
## 9. 订单 HandlerH5
- [ ] 9.1 创建 `internal/handler/h5/order.go`,实现 Create 接口
- [ ] 9.2 实现 Get 接口
- [ ] 9.3 实现 List 接口
- [ ] 9.4 实现 WalletPay 接口
## 10. 支付回调 Handler
- [ ] 10.1 创建 `internal/handler/callback/payment.go`,实现 WechatPayCallback 接口
- [ ] 10.2 实现 AlipayCallback 接口
## 11. Bootstrap 注册
- [ ] 11.1 在 stores.go 中注册 OrderStore, OrderItemStore
- [ ] 11.2 在 services.go 中注册 PurchaseValidationService, OrderService
- [ ] 11.3 在 handlers.go 中注册 AdminOrderHandler, H5OrderHandler, PaymentCallbackHandler
## 12. 路由注册
- [ ] 12.1 注册 `/api/admin/orders` 路由组POST, GET, GET/:id, POST/:id/cancel
- [ ] 12.2 注册 `/api/h5/orders` 路由组POST, GET, GET/:id, POST/:id/pay
- [ ] 12.3 注册 `/api/callback/wechat-pay` 回调路由
- [ ] 12.4 注册 `/api/callback/alipay` 回调路由
## 13. 文档生成器更新
- [ ] 13.1 在 docs.go 和 gendocs/main.go 中添加新 Handler
- [ ] 13.2 执行文档生成验证
## 14. 测试
- [ ] 14.1 OrderStore 单元测试
- [ ] 14.2 PurchaseValidationService 单元测试(覆盖各种验证场景)
- [ ] 14.3 OrderService 单元测试(覆盖创建、支付、取消)
- [ ] 14.4 WalletPay 事务测试(覆盖成功和失败回滚)
- [ ] 14.5 订单创建 API 集成测试
- [ ] 14.6 钱包支付 API 集成测试
- [ ] 14.7 执行 `go test ./...` 确认通过
## 15. 最终验证
- [ ] 15.1 执行 `go build ./...` 确认编译通过
- [ ] 15.2 启动服务,手动测试订单创建流程
- [ ] 15.3 手动测试钱包支付流程
- [ ] 15.4 验证套餐激活PackageUsage 记录创建)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-27

View File

@@ -0,0 +1,217 @@
## Context
Phase 1 完成了套餐系列和套餐的基础管理,但代理商还不能分销套餐。本期实现代理套餐分配机制,使上级代理能够:
1. 为下级店铺分配可销售的套餐系列
2. 通过加价模式设置下级的成本价
3. 配置梯度佣金(基于销量/销售额的阶梯奖励)
**当前代理层级结构**
- 店铺通过 `Shop.parent_id` 维护层级关系
- 最多 7 级代理
- 数据权限通过 `GetSubordinateShopIDs()` 递归查询
## Goals / Non-Goals
**Goals:**
- 实现套餐系列级别的分配机制
- 支持固定金额和百分比两种加价模式
- 支持梯度佣金配置(月度/季度/年度/自定义时间范围)
- 代理能查看自己被分配的套餐及成本价
- 可选的单套餐级别成本价覆盖
**Non-Goals:**
- 不实现卡/设备的套餐系列关联Phase 3
- 不实现订单支付流程Phase 4
- 不实现佣金计算逻辑Phase 5
- 不支持跨级分配(只能分配给直属下级)
## Decisions
### 1. 分配模型设计
**决策**:三个独立模型
```go
// ShopSeriesAllocation 店铺套餐系列分配
type ShopSeriesAllocation struct {
gorm.Model
BaseModel
ShopID uint // 被分配的店铺 ID
SeriesID uint // 套餐系列 ID
AllocatorShopID uint // 分配者店铺 ID上级
PricingMode string // 加价模式: fixed-固定金额 percent-百分比
PricingValue int64 // 加价值(分或千分比)
OneTimeCommissionTrigger string // 一次性佣金触发类型: one_time_recharge-单次充值 accumulated_recharge-累计充值
OneTimeCommissionThreshold int64 // 一次性佣金触发阈值(分)
OneTimeCommissionAmount int64 // 一次性佣金金额(分)
Status int // 状态 1-启用 2-禁用
}
// ShopSeriesCommissionTier 梯度佣金配置
type ShopSeriesCommissionTier struct {
gorm.Model
BaseModel
AllocationID uint // 关联的分配 ID
TierType string // 梯度类型: sales_count-销量 sales_amount-销售额
PeriodType string // 周期类型: monthly-月度 quarterly-季度 yearly-年度 custom-自定义
PeriodStartDate *time.Time // 自定义周期开始日期
PeriodEndDate *time.Time // 自定义周期结束日期
ThresholdValue int64 // 阈值(销量或金额)
CommissionAmount int64 // 佣金金额(分)
}
// ShopPackageAllocation 店铺单套餐分配(可选覆盖)
type ShopPackageAllocation struct {
gorm.Model
BaseModel
ShopID uint // 被分配的店铺 ID
PackageID uint // 套餐 ID
AllocationID uint // 关联的系列分配 ID
CostPrice int64 // 覆盖的成本价(分)
Status int // 状态 1-启用 2-禁用
}
```
**理由**
- 系列级别分配是主要方式,减少配置工作量
- 单套餐分配用于特殊场景(如某个套餐给特定代理优惠价)
- 梯度佣金独立模型,支持多档配置
### 2. 加价模式与成本价计算
**决策**:成本价 = 上级成本价 + 加价值
```
# 固定金额加价
下级成本价 = 上级成本价 + pricing_value
# 百分比加价pricing_value 为千分比,如 100 = 10%
下级成本价 = 上级成本价 × (1 + pricing_value / 1000)
```
**理由**
- 基于上级成本价加价,确保每级都有利润空间
- 千分比精度满足业务需求0.1% 精度)
- 平台作为顶级,其成本价 = Package.suggested_cost_price
**约束**
- 下级成本价 ≥ 上级成本价(禁止负加价)
- 验证时需递归获取上级成本价
### 3. 成本价获取逻辑
**决策**:递归查询 + 缓存
```go
func GetCostPrice(shopID, packageID uint) int64 {
// 1. 检查是否有单套餐覆盖
if override := GetPackageAllocation(shopID, packageID); override != nil {
return override.CostPrice
}
// 2. 获取系列分配
allocation := GetSeriesAllocation(shopID, package.SeriesID)
if allocation == nil {
return 0 // 未分配,不可购买
}
// 3. 获取上级成本价
parentCostPrice := GetParentCostPrice(allocation.AllocatorShopID, packageID)
// 4. 计算当前成本价
return CalculatePrice(parentCostPrice, allocation.PricingMode, allocation.PricingValue)
}
```
**理由**
- 单套餐覆盖优先级最高
- 递归到平台级别时,使用 Package.suggested_cost_price
- 可考虑缓存热点套餐的成本价(后续优化)
### 4. 梯度佣金周期计算
**决策**:支持固定周期和自定义周期
| PeriodType | 计算方式 |
|------------|----------|
| monthly | 当月 1 日 00:00 至月末 23:59:59 |
| quarterly | 当季度第一天至最后一天 |
| yearly | 当年 1 月 1 日至 12 月 31 日 |
| custom | PeriodStartDate 至 PeriodEndDate |
**理由**
- 固定周期覆盖常见场景
- 自定义周期支持促销活动等特殊需求
### 5. API 设计
**决策**RESTful + 嵌套资源
```
# 套餐系列分配
POST /api/admin/shop-series-allocations 为下级分配系列
GET /api/admin/shop-series-allocations 查询分配列表
GET /api/admin/shop-series-allocations/:id 分配详情
PUT /api/admin/shop-series-allocations/:id 更新分配
DELETE /api/admin/shop-series-allocations/:id 删除分配
PATCH /api/admin/shop-series-allocations/:id/status 启用/禁用
# 梯度佣金(嵌套在分配下)
POST /api/admin/shop-series-allocations/:id/tiers 添加梯度
GET /api/admin/shop-series-allocations/:id/tiers 梯度列表
PUT /api/admin/shop-series-allocations/:id/tiers/:tierId 更新梯度
DELETE /api/admin/shop-series-allocations/:id/tiers/:tierId 删除梯度
# 单套餐分配
POST /api/admin/shop-package-allocations 分配单套餐
GET /api/admin/shop-package-allocations 查询列表
PUT /api/admin/shop-package-allocations/:id 更新
DELETE /api/admin/shop-package-allocations/:id 删除
# 代理可售套餐
GET /api/admin/my-packages 查询我的可售套餐
GET /api/admin/my-packages/:id 套餐详情(含成本价)
```
## Risks / Trade-offs
### 风险 1递归成本价计算性能
**风险**:多级代理场景下,递归查询成本价可能较慢
**缓解**
- 首期不做缓存,观察实际性能
- 如有问题,后续增加 Redis 缓存(按 shop_id + package_id 缓存)
- 缓存失效策略:分配变更时清除相关缓存
### 风险 2分配一致性
**风险**:上级删除分配后,下级的分配关系如何处理
**缓解**
- 删除分配时检查是否有下级依赖
- 如有下级依赖,禁止删除或级联禁用
- 本期采用禁止删除策略,要求先清理下级分配
### 风险 3梯度佣金统计复杂度
**风险**:统计周期内的销量/销售额可能涉及大量数据
**缓解**
- 佣金计算在 Phase 5 实现
- 可考虑定时任务预计算周期统计数据
- 本期只做配置,不做实际统计
## Open Questions
1. **是否支持批量分配?**
- 当前设计:单个分配
- 待确认:是否需要批量为多个下级分配同一系列?
2. **分配删除策略?**
- 当前设计:有下级依赖时禁止删除
- 待确认:是否需要级联删除或级联禁用?
3. **梯度佣金是否可叠加?**
- 当前设计:达到最高档位只拿最高档佣金
- 待确认:是否需要累加所有达标档位的佣金?

View File

@@ -0,0 +1,61 @@
## Why
Phase 1 完成了套餐基础模块,但代理商还不能分销套餐。需要实现代理套餐分配机制:上级代理为下级分配套餐系列,设置成本价(通过加价模式计算),并支持梯度佣金配置。代理只能看到和销售被分配的套餐。
## What Changes
**新增模型:**
- `ShopSeriesAllocation`:店铺套餐系列分配,记录哪个店铺被分配了哪个套餐系列、成本价加价模式、**一次性佣金触发配置**
- `ShopSeriesCommissionTier`:梯度佣金配置,基于销量/销售额设置不同的阶梯奖励金额
- `ShopPackageAllocation`:店铺单套餐分配(可选),用于覆盖系列级别的成本价设置
**新增 API**
- 为下级店铺分配套餐系列(设置加价模式)
- 查询店铺的套餐系列分配列表
- 更新/删除套餐系列分配
- 配置梯度佣金(按系列)
- 为下级店铺分配单个套餐(覆盖成本价)
- 代理查看自己可销售的套餐列表(含成本价)
**业务规则:**
- 加价模式:固定金额加价 或 百分比加价(基于上级成本价)
- 代理给下级设置的成本价 ≥ 自己的成本价(不可亏本)
- 梯度佣金支持时间范围配置(月度/季度/年度/自定义)
- 套餐系列分配是主要方式,单套餐分配用于特殊覆盖
## Capabilities
### New Capabilities
- `shop-series-allocation`: 店铺套餐系列分配 - 为下级店铺分配套餐系列,设置加价模式计算成本价
- `shop-commission-tier`: 梯度佣金配置 - 基于销量/销售额配置不同档位的一次性佣金
- `shop-package-allocation`: 店铺单套餐分配 - 可选的单套餐级别成本价覆盖
- `agent-available-packages`: 代理可售套餐查询 - 代理查看自己被分配的套餐及成本价
### Modified Capabilities
<!-- 无 -->
## Impact
**代码影响:**
- `internal/model/` - 新增 3 个模型文件
- `migrations/` - 创建 3 个新表
- `internal/handler/admin/` - 新增分配管理 Handler
- `internal/service/` - 新增分配管理 Service
- `internal/store/postgres/` - 新增 3 个 Store
- `internal/model/dto/` - 新增请求/响应 DTO
- `internal/bootstrap/` - 注册新组件
- `internal/router/` - 注册新路由
**API 影响:**
- 新增 `/api/admin/shop-series-allocations/*` 路由组
- 新增 `/api/admin/shop-package-allocations/*` 路由组
- 新增 `/api/admin/my-packages` 代理可售套餐查询
**数据库影响:**
- 新增表:`tb_shop_series_allocation`, `tb_shop_series_commission_tier`, `tb_shop_package_allocation`
**依赖关系:**
- 依赖 Phase 1add-package-module完成
- Phase 3卡/设备关联)依赖本期

View File

@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: 查询代理可售套餐列表
系统 SHALL 允许代理查询自己被分配的所有套餐。结果 MUST 包含套餐信息和代理的成本价。支持按套餐系列筛选、按套餐类型筛选。
#### Scenario: 查询所有可售套餐
- **WHEN** 代理查询可售套餐列表
- **THEN** 系统返回该代理被分配的所有套餐系列下的启用且上架的套餐
#### Scenario: 响应包含成本价
- **WHEN** 代理查询可售套餐
- **THEN** 每个套餐包含:套餐信息、建议售价、代理成本价、利润空间
#### Scenario: 按系列筛选
- **WHEN** 代理指定套餐系列 ID 筛选
- **THEN** 系统只返回该系列下的套餐
#### Scenario: 只返回可售套餐
- **WHEN** 代理查询可售套餐
- **THEN** 系统只返回状态为启用(1)且上架状态为上架(1)的套餐
---
### Requirement: 查询代理可售套餐详情
系统 SHALL 允许代理查询单个套餐的详细信息,包含完整的价格信息。
#### Scenario: 查询可售套餐详情
- **WHEN** 代理查询指定套餐的详情
- **THEN** 系统返回套餐完整信息,包含:成本价、建议售价、价格来源(系列加价/单套餐覆盖)
#### Scenario: 查询未分配的套餐
- **WHEN** 代理查询一个未被分配的套餐详情
- **THEN** 系统返回 "您没有该套餐的销售权限" 错误
---
### Requirement: 成本价计算优先级
系统计算代理成本价时 MUST 遵循以下优先级:
1. 单套餐覆盖价(如果存在且启用)
2. 系列级别加价计算
#### Scenario: 存在单套餐覆盖
- **WHEN** 代理查询一个有覆盖价的套餐
- **THEN** 成本价使用覆盖价,价格来源标记为 "单套餐覆盖"
#### Scenario: 使用系列加价
- **WHEN** 代理查询一个无覆盖价的套餐
- **THEN** 成本价 = 上级成本价 + 加价值,价格来源标记为 "系列加价"
---
### Requirement: 查询代理被分配的套餐系列
系统 SHALL 允许代理查询自己被分配的套餐系列列表。
#### Scenario: 查询被分配的系列
- **WHEN** 代理查询自己的套餐系列分配
- **THEN** 系统返回所有分配给该代理的套餐系列(启用状态的)
#### Scenario: 响应包含系列下套餐数量
- **WHEN** 代理查询被分配的系列
- **THEN** 每个系列包含:系列信息、可售套餐数量、加价模式信息

View File

@@ -0,0 +1,77 @@
## ADDED Requirements
### Requirement: 配置梯度佣金
系统 SHALL 允许代理为套餐系列分配配置梯度佣金。每个梯度包含:梯度类型(销量/销售额)、周期类型、阈值、佣金金额。
#### Scenario: 添加销量梯度佣金
- **WHEN** 代理为分配添加梯度:类型=销量,周期=月度,阈值=100佣金=5000分
- **THEN** 系统创建梯度配置,当下级月销量达到 100 时可获得 50 元佣金
#### Scenario: 添加销售额梯度佣金
- **WHEN** 代理添加梯度:类型=销售额,周期=季度,阈值=100000分佣金=10000分
- **THEN** 系统创建梯度配置,当下级季度销售额达到 1000 元时可获得 100 元佣金
#### Scenario: 配置自定义周期
- **WHEN** 代理添加梯度,周期类型=自定义,指定开始和结束日期
- **THEN** 系统创建梯度配置,统计指定日期范围内的数据
#### Scenario: 添加多个梯度档位
- **WHEN** 代理为同一分配添加多个梯度100件=50元200件=120元500件=350元
- **THEN** 系统创建多个梯度记录,支持阶梯奖励
---
### Requirement: 查询梯度佣金配置
系统 SHALL 提供梯度佣金配置的查询功能,按分配 ID 查询。
#### Scenario: 查询分配的梯度配置
- **WHEN** 代理查询指定分配的梯度配置
- **THEN** 系统返回该分配下的所有梯度配置,按阈值升序排列
#### Scenario: 分配无梯度配置
- **WHEN** 代理查询一个没有配置梯度的分配
- **THEN** 系统返回空列表
---
### Requirement: 更新梯度佣金配置
系统 SHALL 允许代理更新梯度配置的阈值和佣金金额。
#### Scenario: 更新梯度阈值
- **WHEN** 代理将梯度阈值从 100 改为 150
- **THEN** 系统更新梯度记录
#### Scenario: 更新梯度佣金金额
- **WHEN** 代理将佣金金额从 5000 改为 6000
- **THEN** 系统更新梯度记录
---
### Requirement: 删除梯度佣金配置
系统 SHALL 允许代理删除梯度配置。
#### Scenario: 删除梯度配置
- **WHEN** 代理删除指定的梯度配置
- **THEN** 系统软删除该梯度记录
---
### Requirement: 梯度佣金周期类型
系统 MUST 支持以下周期类型:
- monthly月度当月 1 日至月末)
- quarterly季度当季第一天至最后一天
- yearly年度1 月 1 日至 12 月 31 日)
- custom自定义指定开始和结束日期
#### Scenario: 月度周期
- **WHEN** 配置月度周期的梯度
- **THEN** 统计范围为当月 1 日 00:00:00 至月末 23:59:59
#### Scenario: 自定义周期必填日期
- **WHEN** 代理选择自定义周期但未提供开始或结束日期
- **THEN** 系统返回参数验证错误

View File

@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: 为下级店铺分配单个套餐
系统 SHALL 允许代理为下级店铺的特定套餐设置覆盖成本价。此功能用于对单个套餐给予特殊定价,优先级高于系列级别的加价计算。
#### Scenario: 成功分配单套餐覆盖价
- **WHEN** 代理为下级的某个套餐设置覆盖成本价 8000 分
- **THEN** 系统创建单套餐分配记录,该下级购买此套餐时成本价为 8000 分(不再使用系列加价计算)
#### Scenario: 覆盖价低于上级成本价
- **WHEN** 代理尝试设置的覆盖价低于自己的成本价
- **THEN** 系统返回错误 "覆盖价不能低于您的成本价"
#### Scenario: 套餐未在系列分配中
- **WHEN** 代理尝试为一个未分配系列下的套餐设置覆盖价
- **THEN** 系统返回错误 "该套餐的系列未分配给此店铺"
---
### Requirement: 查询单套餐分配列表
系统 SHALL 提供单套餐分配的查询功能,支持按店铺、套餐、状态筛选。
#### Scenario: 查询店铺的单套餐分配
- **WHEN** 代理查询指定店铺的单套餐分配列表
- **THEN** 系统返回该店铺的所有单套餐覆盖配置
#### Scenario: 查询结果包含套餐信息
- **WHEN** 代理查询单套餐分配列表
- **THEN** 响应包含套餐名称、套餐编码、原计算成本价、覆盖成本价
---
### Requirement: 更新单套餐分配
系统 SHALL 允许代理更新单套餐分配的覆盖成本价。
#### Scenario: 更新覆盖成本价
- **WHEN** 代理将覆盖成本价从 8000 改为 7500
- **THEN** 系统更新记录,下级的该套餐成本价变为 7500
---
### Requirement: 删除单套餐分配
系统 SHALL 允许代理删除单套餐分配。删除后恢复使用系列级别的加价计算。
#### Scenario: 删除单套餐覆盖
- **WHEN** 代理删除单套餐分配记录
- **THEN** 系统软删除记录,下级的该套餐成本价恢复为系列加价计算值
---
### Requirement: 单套餐分配状态管理
系统 SHALL 允许代理启用/禁用单套餐分配。禁用后恢复使用系列级别价格。
#### Scenario: 禁用单套餐覆盖
- **WHEN** 代理禁用单套餐分配
- **THEN** 该套餐暂时使用系列级别的加价计算
#### Scenario: 启用单套餐覆盖
- **WHEN** 代理启用已禁用的单套餐分配
- **THEN** 该套餐恢复使用覆盖成本价

View File

@@ -0,0 +1,103 @@
## ADDED Requirements
### Requirement: 为下级店铺分配套餐系列
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定加价模式(固定金额或百分比)和加价值。可选配置一次性佣金触发条件(触发类型、阈值、金额)。分配者只能分配自己已被分配的套餐系列。
#### Scenario: 成功分配套餐系列
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置固定金额加价 1000 分
- **THEN** 系统创建分配记录,下级成本价 = 上级成本价 + 1000
#### Scenario: 百分比加价分配
- **WHEN** 代理设置百分比加价模式,加价值为 10010%
- **THEN** 系统创建分配记录,下级成本价 = 上级成本价 × 1.1
#### Scenario: 尝试分配未拥有的系列
- **WHEN** 代理尝试分配自己未被分配的套餐系列
- **THEN** 系统返回错误 "您没有该套餐系列的分配权限"
#### Scenario: 尝试分配给非直属下级
- **WHEN** 代理尝试分配给非直属下级店铺
- **THEN** 系统返回错误 "只能为直属下级分配套餐"
#### Scenario: 重复分配同一系列
- **WHEN** 代理尝试为同一下级店铺重复分配同一套餐系列
- **THEN** 系统返回错误 "该店铺已分配此套餐系列"
#### Scenario: 配置一次性佣金触发条件
- **WHEN** 代理分配时设置一次性佣金触发类型为"单次充值",阈值 30000 分,金额 5000 分
- **THEN** 系统创建分配记录,下级的卡/设备在单次充值 ≥ 300 元时可获得 50 元一次性佣金
#### Scenario: 配置累计充值触发条件
- **WHEN** 代理分配时设置一次性佣金触发类型为"累计充值",阈值 50000 分,金额 8000 分
- **THEN** 系统创建分配记录,下级的卡/设备在累计充值 ≥ 500 元时可获得 80 元一次性佣金
---
### Requirement: 查询套餐系列分配列表
系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。结果 MUST 包含计算后的成本价。
#### Scenario: 查询所有分配
- **WHEN** 代理查询分配列表,不带筛选条件
- **THEN** 系统返回该代理创建的所有分配记录
#### Scenario: 按店铺筛选
- **WHEN** 代理指定下级店铺 ID 筛选
- **THEN** 系统只返回该店铺的分配记录
#### Scenario: 响应包含成本价
- **WHEN** 代理查询分配列表
- **THEN** 每条记录包含计算后的下级成本价
---
### Requirement: 更新套餐系列分配
系统 SHALL 允许代理更新分配的加价模式和加价值。更新后下级的成本价 MUST 同步变化。
#### Scenario: 更新加价值
- **WHEN** 代理将加价值从 1000 改为 2000
- **THEN** 系统更新分配记录,下级成本价相应增加
#### Scenario: 更新不存在的分配
- **WHEN** 代理更新不存在的分配 ID
- **THEN** 系统返回 "分配记录不存在" 错误
---
### Requirement: 删除套餐系列分配
系统 SHALL 允许代理删除分配记录。如果有下级依赖此分配MUST 禁止删除。
#### Scenario: 成功删除无依赖的分配
- **WHEN** 代理删除一个没有下级依赖的分配记录
- **THEN** 系统软删除该记录
#### Scenario: 尝试删除有下级依赖的分配
- **WHEN** 代理尝试删除一个已被下级使用的分配(下级基于此分配又分配给了更下级)
- **THEN** 系统返回错误 "存在下级依赖,无法删除"
---
### Requirement: 启用/禁用套餐系列分配
系统 SHALL 允许代理切换分配的启用状态。禁用后下级 MUST NOT 能使用该分配购买套餐。
#### Scenario: 禁用分配
- **WHEN** 代理将分配状态设为禁用
- **THEN** 系统更新状态,下级无法基于此分配购买套餐
#### Scenario: 启用分配
- **WHEN** 代理将禁用的分配设为启用
- **THEN** 系统更新状态,下级可以继续使用
---
### Requirement: 平台分配套餐系列
平台管理员 SHALL 能够为一级代理分配套餐系列。平台的成本价基准为 Package.suggested_cost_price。
#### Scenario: 平台为一级代理分配
- **WHEN** 平台管理员为一级代理分配套餐系列
- **THEN** 系统创建分配记录,一级代理成本价 = suggested_cost_price + 加价值

View File

@@ -0,0 +1,167 @@
## 1. 新增模型
- [ ] 1.1 创建 `internal/model/shop_series_allocation.go`,定义 ShopSeriesAllocation 模型shop_id, series_id, allocator_shop_id, pricing_mode, pricing_value, one_time_commission_trigger, one_time_commission_threshold, one_time_commission_amount, status
- [ ] 1.2 创建 `internal/model/shop_series_commission_tier.go`,定义 ShopSeriesCommissionTier 模型allocation_id, tier_type, period_type, period_start_date, period_end_date, threshold_value, commission_amount
- [ ] 1.3 创建 `internal/model/shop_package_allocation.go`,定义 ShopPackageAllocation 模型shop_id, package_id, allocation_id, cost_price, status
## 2. 数据库迁移
- [ ] 2.1 创建迁移文件,创建 tb_shop_series_allocation 表
- [ ] 2.2 创建 tb_shop_series_commission_tier 表
- [ ] 2.3 创建 tb_shop_package_allocation 表
- [ ] 2.4 添加必要的索引shop_id, series_id, allocation_id
- [ ] 2.5 本地执行迁移验证
## 3. 套餐系列分配 DTO
- [ ] 3.1 创建 `internal/model/dto/shop_series_allocation.go`,定义 CreateShopSeriesAllocationRequest含 one_time_commission_trigger, one_time_commission_threshold, one_time_commission_amount 可选字段)
- [ ] 3.2 定义 UpdateShopSeriesAllocationRequest
- [ ] 3.3 定义 ShopSeriesAllocationListRequest支持 shop_id, series_id, status 筛选)
- [ ] 3.4 定义 UpdateStatusRequest
- [ ] 3.5 定义 ShopSeriesAllocationResponse包含计算后的成本价
## 4. 梯度佣金 DTO
- [ ] 4.1 定义 CreateCommissionTierRequesttier_type, period_type, period_start_date, period_end_date, threshold_value, commission_amount
- [ ] 4.2 定义 UpdateCommissionTierRequest
- [ ] 4.3 定义 CommissionTierResponse
## 5. 单套餐分配 DTO
- [ ] 5.1 创建 `internal/model/dto/shop_package_allocation.go`,定义 CreateShopPackageAllocationRequest
- [ ] 5.2 定义 UpdateShopPackageAllocationRequest
- [ ] 5.3 定义 ShopPackageAllocationListRequest
- [ ] 5.4 定义 ShopPackageAllocationResponse
## 6. 代理可售套餐 DTO
- [ ] 6.1 定义 MyPackageListRequestseries_id, package_type 筛选)
- [ ] 6.2 定义 MyPackageResponse包含成本价、建议售价、价格来源
- [ ] 6.3 定义 MySeriesAllocationResponse
## 7. 套餐系列分配 Store
- [ ] 7.1 创建 `internal/store/postgres/shop_series_allocation_store.go`,实现 Create 方法
- [ ] 7.2 实现 GetByID 方法
- [ ] 7.3 实现 GetByShopAndSeries 方法(检查重复分配)
- [ ] 7.4 实现 Update 方法
- [ ] 7.5 实现 Delete 方法
- [ ] 7.6 实现 List 方法(支持分页和筛选)
- [ ] 7.7 实现 UpdateStatus 方法
- [ ] 7.8 实现 HasDependentAllocations 方法(检查下级依赖)
- [ ] 7.9 实现 GetByShopID 方法(获取店铺的所有分配)
## 8. 梯度佣金 Store
- [ ] 8.1 创建 `internal/store/postgres/shop_series_commission_tier_store.go`,实现 Create 方法
- [ ] 8.2 实现 GetByID 方法
- [ ] 8.3 实现 Update 方法
- [ ] 8.4 实现 Delete 方法
- [ ] 8.5 实现 ListByAllocationID 方法
## 9. 单套餐分配 Store
- [ ] 9.1 创建 `internal/store/postgres/shop_package_allocation_store.go`,实现 Create 方法
- [ ] 9.2 实现 GetByID 方法
- [ ] 9.3 实现 GetByShopAndPackage 方法
- [ ] 9.4 实现 Update 方法
- [ ] 9.5 实现 Delete 方法
- [ ] 9.6 实现 List 方法
- [ ] 9.7 实现 UpdateStatus 方法
## 10. 套餐系列分配 Service
- [ ] 10.1 创建 `internal/service/shop_series_allocation/service.go`,实现 Create 方法(验证权限、检查重复、计算成本价)
- [ ] 10.2 实现 Get 方法
- [ ] 10.3 实现 Update 方法
- [ ] 10.4 实现 Delete 方法(检查下级依赖)
- [ ] 10.5 实现 List 方法
- [ ] 10.6 实现 UpdateStatus 方法
- [ ] 10.7 实现 GetParentCostPrice 辅助方法(递归获取上级成本价)
- [ ] 10.8 实现 CalculateCostPrice 辅助方法(根据加价模式计算)
## 11. 梯度佣金 Service
- [ ] 11.1 在 shop_series_allocation service 中实现 AddTier 方法
- [ ] 11.2 实现 UpdateTier 方法
- [ ] 11.3 实现 DeleteTier 方法
- [ ] 11.4 实现 ListTiers 方法
## 12. 单套餐分配 Service
- [ ] 12.1 创建 `internal/service/shop_package_allocation/service.go`,实现 Create 方法(验证系列已分配、验证成本价)
- [ ] 12.2 实现 Get 方法
- [ ] 12.3 实现 Update 方法
- [ ] 12.4 实现 Delete 方法
- [ ] 12.5 实现 List 方法
- [ ] 12.6 实现 UpdateStatus 方法
## 13. 代理可售套餐 Service
- [ ] 13.1 创建 `internal/service/my_package/service.go`,实现 ListMyPackages 方法(获取可售套餐列表)
- [ ] 13.2 实现 GetMyPackage 方法(获取单个套餐详情含成本价)
- [ ] 13.3 实现 ListMySeriesAllocations 方法(获取被分配的系列)
- [ ] 13.4 实现 GetCostPrice 核心方法(成本价计算,考虑优先级)
## 14. 套餐系列分配 Handler
- [ ] 14.1 创建 `internal/handler/admin/shop_series_allocation.go`,实现 Create 接口
- [ ] 14.2 实现 Get 接口
- [ ] 14.3 实现 Update 接口
- [ ] 14.4 实现 Delete 接口
- [ ] 14.5 实现 List 接口
- [ ] 14.6 实现 UpdateStatus 接口
- [ ] 14.7 实现 AddTier 接口
- [ ] 14.8 实现 UpdateTier 接口
- [ ] 14.9 实现 DeleteTier 接口
- [ ] 14.10 实现 ListTiers 接口
## 15. 单套餐分配 Handler
- [ ] 15.1 创建 `internal/handler/admin/shop_package_allocation.go`,实现 Create 接口
- [ ] 15.2 实现 Get 接口
- [ ] 15.3 实现 Update 接口
- [ ] 15.4 实现 Delete 接口
- [ ] 15.5 实现 List 接口
- [ ] 15.6 实现 UpdateStatus 接口
## 16. 代理可售套餐 Handler
- [ ] 16.1 创建 `internal/handler/admin/my_package.go`,实现 ListMyPackages 接口
- [ ] 16.2 实现 GetMyPackage 接口
- [ ] 16.3 实现 ListMySeriesAllocations 接口
## 17. Bootstrap 注册
- [ ] 17.1 在 stores.go 中注册 ShopSeriesAllocationStore, ShopSeriesCommissionTierStore, ShopPackageAllocationStore
- [ ] 17.2 在 services.go 中注册 ShopSeriesAllocationService, ShopPackageAllocationService, MyPackageService
- [ ] 17.3 在 handlers.go 中注册 ShopSeriesAllocationHandler, ShopPackageAllocationHandler, MyPackageHandler
## 18. 路由注册
- [ ] 18.1 注册 `/api/admin/shop-series-allocations` 路由组
- [ ] 18.2 注册 `/api/admin/shop-series-allocations/:id/tiers` 嵌套路由
- [ ] 18.3 注册 `/api/admin/shop-package-allocations` 路由组
- [ ] 18.4 注册 `/api/admin/my-packages` 路由
- [ ] 18.5 注册 `/api/admin/my-series-allocations` 路由
## 19. 文档生成器更新
- [ ] 19.1 在 docs.go 和 gendocs/main.go 中添加新 Handler
- [ ] 19.2 执行文档生成验证
## 20. 测试
- [ ] 20.1 ShopSeriesAllocationStore 单元测试
- [ ] 20.2 ShopPackageAllocationStore 单元测试
- [ ] 20.3 ShopSeriesAllocationService 单元测试(覆盖权限验证、成本价计算)
- [ ] 20.4 MyPackageService 单元测试(覆盖成本价优先级)
- [ ] 20.5 套餐系列分配 API 集成测试
- [ ] 20.6 代理可售套餐 API 集成测试
- [ ] 20.7 执行 `go test ./...` 确认通过
## 21. 最终验证
- [ ] 21.1 执行 `go build ./...` 确认编译通过
- [ ] 21.2 启动服务,手动测试分配流程
- [ ] 21.3 验证成本价计算逻辑正确

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-27

View File

@@ -0,0 +1,199 @@
## Context
当前系统中存在大量为号卡业务设计的分佣模型(冻结/解冻、组合分佣、运营商结算等),但流量卡业务只需要简单的一次性佣金机制。这些模型增加了代码复杂度且从未使用。
现有套餐模型 `Package` 缺少建议价格字段和上架状态管理,无法支持后续的代理套餐分配功能。
**当前代码结构**
- Handler 在 `internal/handler/admin/` 下,每个模块一个文件
- Service 在 `internal/service/{module}/service.go`,每个模块一个包
- Store 在 `internal/store/postgres/{module}_store.go`
- Bootstrap 在 `internal/bootstrap/` 负责组件注册
## Goals / Non-Goals
**Goals:**
- 清理 8 个废弃模型,减少代码复杂度
- 扩展 Package 模型支持建议价格和上架状态
- 提供完整的套餐系列 CRUD API
- 提供完整的套餐 CRUD API含双状态管理
- 遵循现有代码架构风格
**Non-Goals:**
- 不实现代理套餐分配Phase 2
- 不实现一次性佣金计算Phase 5
- 不迁移现有数据(表内无数据)
- 不修改 `CommissionRecord` 模型(后续 Phase 简化)
## Decisions
### 1. 模型文件处理策略
**决策**:直接删除废弃模型定义,不保留注释或空文件
**理由**
- 这些模型从未在生产环境使用
- Git 历史可追溯
- 保留空定义增加维护负担
**替代方案**
- ❌ 标记为 deprecated 保留:增加代码噪音
- ❌ 移到 archive 目录:过度设计
### 2. Package 模型字段设计
**决策**:新增三个字段
```go
type Package struct {
// ... 现有字段 ...
SuggestedCostPrice int64 `gorm:"column:suggested_cost_price;type:bigint;default:0;comment:建议成本价(分为单位)" json:"suggested_cost_price"`
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
}
```
**理由**
- `suggested_cost_price`:平台定义的建议成本价,代理分配时参考
- `suggested_retail_price`:平台定义的建议零售价,代理设置售价时参考
- `shelf_status`:与 `status`(启用/禁用)分离,支持独立的上架控制
- 默认 `shelf_status=2`(下架):新套餐需要显式上架
**替代方案**
- ❌ 用 JSON 字段存储扩展属性:查询不便,类型不安全
- ❌ 合并 status 和 shelf_status语义不同分开更清晰
### 3. 双状态业务规则
**决策**启用状态status和上架状态shelf_status独立但有约束
| status | shelf_status | 允许操作 |
|--------|--------------|----------|
| 启用(1) | 上架(1) | 可购买 |
| 启用(1) | 下架(2) | 不可购买,可上架 |
| 禁用(2) | 上架(1) | ❌ 禁止 - 禁用时强制下架 |
| 禁用(2) | 下架(2) | 不可购买,需先启用再上架 |
**理由**
- 禁用套餐不应该可购买,强制下架保证数据一致性
- 启用但下架:允许平台配置套餐但暂不开放购买
### 4. API 路由设计
**决策**:使用 RESTful 风格,状态变更使用 PATCH
```
# 套餐系列
POST /api/admin/package-series 创建
GET /api/admin/package-series 列表
GET /api/admin/package-series/:id 详情
PUT /api/admin/package-series/:id 更新
DELETE /api/admin/package-series/:id 删除
PATCH /api/admin/package-series/:id/status 启用/禁用
# 套餐
POST /api/admin/packages 创建
GET /api/admin/packages 列表
GET /api/admin/packages/:id 详情
PUT /api/admin/packages/:id 更新
DELETE /api/admin/packages/:id 删除
PATCH /api/admin/packages/:id/status 启用/禁用
PATCH /api/admin/packages/:id/shelf 上架/下架
```
**理由**
- 与现有 API 风格一致(参考 `/api/admin/carriers`
- 状态变更使用 PATCH 符合 HTTP 语义
- 路径清晰,易于前端对接
### 5. Service 层设计
**决策**:每个模块独立 Service 包
```
internal/service/package_series/service.go # 套餐系列 Service
internal/service/package/service.go # 套餐 Service
```
**理由**
- 与现有架构一致carrier, iot_card 等)
- 便于后续扩展(如套餐关联其他模块)
### 6. 数据库迁移策略
**决策**:单个迁移文件,先删后改
迁移顺序:
1. DROP 8 个废弃表
2. ALTER tb_package 添加 3 个新字段
**理由**
- 这些表无生产数据,可直接删除
- 单文件便于回滚
## Risks / Trade-offs
### 风险 1删除模型后发现有隐藏引用
**风险**:代码中可能有对废弃模型的隐藏引用导致编译失败
**缓解**
- 删除模型后执行 `go build ./...` 确认编译通过
- 使用 IDE 全局搜索确认无引用
### 风险 2双状态逻辑复杂度
**风险**:禁用时强制下架的逻辑可能被遗漏
**缓解**
- 在 Service 层统一处理状态变更逻辑
- 添加单元测试覆盖所有状态组合
### 风险 3API 命名与现有冲突
**风险**`/packages` 路径可能与未来其他套餐类型冲突
**缓解**
- 当前只有流量卡套餐,命名合理
- 未来如有号卡套餐,可使用 `/number-card-packages`
## Migration Plan
### 部署步骤
1. **代码部署前**
- 确认生产环境废弃表无数据
- 备份数据库(预防措施)
2. **执行迁移**
```bash
go run cmd/migrate/main.go up
```
3. **验证**
- 确认 8 个表已删除
- 确认 tb_package 新增 3 个字段
- API 健康检查
### 回滚策略
```bash
go run cmd/migrate/main.go down
```
迁移 down 脚本:
- 重建 8 个废弃表(结构保留)
- 删除 tb_package 的 3 个新字段
**注意**:回滚不恢复数据,仅恢复表结构
## Open Questions
1. **套餐系列禁用是否级联影响套餐?**
- 当前设计:不级联,套餐系列禁用只影响系列本身
- 待确认:是否需要禁用系列时自动禁用下属套餐?
2. **删除套餐/套餐系列的约束?**
- 当前设计物理删除soft delete via GORM
- 待确认:是否需要检查关联数据(如已分配给代理的套餐)?
- 建议Phase 2 实现代理分配后再添加约束检查

View File

@@ -0,0 +1,63 @@
## Why
当前分佣模型过于复杂(包含冻结/解冻审批、组合分佣、号卡结算等),而流量卡业务只需要简单的一次性佣金。现有的 `AgentPackageAllocation` 模型也不支持套餐系列级别的分配和梯度佣金配置。需要清理废弃模型,调整 Package 模型支持建议价格和上架状态,并提供完整的套餐/套餐系列 CRUD API。
## What Changes
**模型清理commission.go**
- **BREAKING** 删除 `AgentHierarchy` - 代理层级通过 `Shop.parent_id` 维护
- **BREAKING** 删除 `CommissionRule` - 过于复杂,后续用新模型替代
- **BREAKING** 删除 `CommissionLadder` - 后续用 `ShopSeriesCommissionTier` 替代
- **BREAKING** 删除 `CommissionCombinedCondition` - 流量卡不需要组合分佣
- **BREAKING** 删除 `CommissionApproval` - 不需要冻结/解冻审批流程
- **BREAKING** 删除 `CommissionTemplate` - 简化后不需要模板
- **BREAKING** 删除 `CarrierSettlement` - 号卡专用,本期不做
**模型清理package.go**
- **BREAKING** 删除 `AgentPackageAllocation` - 用新的分配模型替代
**Package 模型调整:**
- 新增 `suggested_cost_price` 字段(建议成本价,分为单位)
- 新增 `suggested_retail_price` 字段(建议售价,分为单位)
- 新增 `shelf_status` 字段上架状态1-上架 2-下架)
**新增 API**
- 套餐系列 CRUD创建、更新、删除、列表、详情、启用/禁用)
- 套餐 CRUD创建、更新、删除、列表、详情、启用/禁用、上架/下架)
## Capabilities
### New Capabilities
- `package-series-management`: 套餐系列管理 - 创建/更新/删除/列表/详情,支持启用/禁用状态切换
- `package-management`: 套餐管理 - 创建/更新/删除/列表/详情,支持启用/禁用和上架/下架双状态管理
### Modified Capabilities
<!-- 无需修改现有 capability这是全新的套餐管理模块 -->
## Impact
**代码影响:**
- `internal/model/commission.go` - 删除 7 个模型
- `internal/model/package.go` - 删除 1 个模型,修改 Package 模型
- `migrations/` - 需要创建迁移文件删除废弃表、修改 package 表
- `internal/handler/admin/` - 新增套餐系列和套餐管理 Handler
- `internal/service/` - 新增套餐系列和套餐管理 Service
- `internal/store/postgres/` - 新增套餐系列和套餐 Store
- `internal/model/dto/` - 新增请求/响应 DTO
- `internal/bootstrap/` - 注册新的 Store/Service/Handler
- `internal/router/` - 注册新的 API 路由
- `cmd/api/docs.go``cmd/gendocs/main.go` - 更新文档生成器
**API 影响:**
- 新增 `/api/admin/package-series/*` 路由组
- 新增 `/api/admin/packages/*` 路由组
**数据库影响:**
- 删除表:`tb_agent_hierarchy`, `tb_commission_rule`, `tb_commission_ladder`, `tb_commission_combined_condition`, `tb_commission_approval`, `tb_commission_template`, `tb_carrier_settlement`, `tb_agent_package_allocation`
- 修改表:`tb_package` 新增 3 个字段
**依赖关系:**
- 本期不涉及外部依赖变更
- 后续 Phase 2代理套餐分配依赖本期完成

View File

@@ -0,0 +1,180 @@
## ADDED Requirements
### Requirement: 创建套餐
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、流量配置、价格和建议价格。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
#### Scenario: 成功创建套餐
- **WHEN** 管理员提交有效的套餐信息
- **THEN** 系统创建套餐记录,状态为启用(1),上架状态为下架(2),返回创建的套餐详情
#### Scenario: 套餐编码重复
- **WHEN** 管理员提交的套餐编码已存在(未删除)
- **THEN** 系统返回错误 "套餐编码已存在"
#### Scenario: 关联不存在的套餐系列
- **WHEN** 管理员指定的系列 ID 不存在
- **THEN** 系统返回错误 "套餐系列不存在"
#### Scenario: 缺少必填字段
- **WHEN** 管理员未提供必填字段(套餐编码、套餐名称、套餐类型、时长、价格)
- **THEN** 系统返回参数验证错误
---
### Requirement: 查询套餐列表
系统 SHALL 提供套餐列表查询功能,支持按套餐名称模糊搜索、按系列 ID 筛选、按状态筛选、按上架状态筛选、按套餐类型筛选。结果 MUST 分页返回,按创建时间倒序排列。
#### Scenario: 查询所有套餐
- **WHEN** 管理员请求套餐列表,不带筛选条件
- **THEN** 系统返回所有未删除的套餐,分页显示
#### Scenario: 按系列筛选
- **WHEN** 管理员指定套餐系列 ID
- **THEN** 系统只返回属于该系列的套餐
#### Scenario: 按名称搜索
- **WHEN** 管理员提供套餐名称关键字
- **THEN** 系统返回名称包含该关键字的套餐
#### Scenario: 按状态筛选
- **WHEN** 管理员指定启用状态
- **THEN** 系统只返回匹配启用状态的套餐
#### Scenario: 按上架状态筛选
- **WHEN** 管理员指定上架状态
- **THEN** 系统只返回匹配上架状态的套餐
#### Scenario: 按套餐类型筛选
- **WHEN** 管理员指定套餐类型formal/addon
- **THEN** 系统只返回匹配类型的套餐
---
### Requirement: 查询套餐详情
系统 SHALL 允许管理员查询单个套餐的详细信息。
#### Scenario: 查询存在的套餐
- **WHEN** 管理员请求指定 ID 的套餐详情
- **THEN** 系统返回该套餐的完整信息
#### Scenario: 查询不存在的套餐
- **WHEN** 管理员请求不存在或已删除的套餐 ID
- **THEN** 系统返回 "套餐不存在" 错误
---
### Requirement: 更新套餐
系统 SHALL 允许管理员更新套餐的基本信息。套餐编码创建后 MUST NOT 允许修改。
#### Scenario: 成功更新套餐
- **WHEN** 管理员提交有效的更新信息
- **THEN** 系统更新套餐记录,返回更新后的详情
#### Scenario: 尝试修改套餐编码
- **WHEN** 管理员尝试修改套餐编码
- **THEN** 系统忽略套餐编码字段,不进行修改
#### Scenario: 更新不存在的套餐
- **WHEN** 管理员更新不存在的套餐
- **THEN** 系统返回 "套餐不存在" 错误
#### Scenario: 关联不存在的套餐系列
- **WHEN** 管理员将套餐关联到不存在的系列
- **THEN** 系统返回错误 "套餐系列不存在"
---
### Requirement: 删除套餐
系统 SHALL 允许管理员删除套餐(软删除)。
#### Scenario: 成功删除套餐
- **WHEN** 管理员删除指定的套餐
- **THEN** 系统软删除该记录,后续查询不再返回
#### Scenario: 删除不存在的套餐
- **WHEN** 管理员删除不存在的套餐
- **THEN** 系统返回 "套餐不存在" 错误
---
### Requirement: 启用/禁用套餐
系统 SHALL 允许管理员切换套餐的启用状态。禁用套餐时 MUST 同时将上架状态设置为下架。
#### Scenario: 启用套餐
- **WHEN** 管理员将禁用的套餐设置为启用
- **THEN** 系统更新状态为启用(1),上架状态保持不变
#### Scenario: 禁用套餐
- **WHEN** 管理员将启用的套餐设置为禁用
- **THEN** 系统更新状态为禁用(2),同时将上架状态设置为下架(2)
#### Scenario: 禁用已上架的套餐
- **WHEN** 管理员禁用一个当前已上架的套餐
- **THEN** 系统更新状态为禁用(2),上架状态强制设置为下架(2)
---
### Requirement: 上架/下架套餐
系统 SHALL 允许管理员切换套餐的上架状态。只有启用状态的套餐才能上架。
#### Scenario: 上架启用的套餐
- **WHEN** 管理员将启用且下架的套餐设置为上架
- **THEN** 系统更新上架状态为上架(1)
#### Scenario: 尝试上架禁用的套餐
- **WHEN** 管理员尝试上架一个禁用的套餐
- **THEN** 系统返回错误 "禁用的套餐不能上架,请先启用"
#### Scenario: 下架套餐
- **WHEN** 管理员将上架的套餐设置为下架
- **THEN** 系统更新上架状态为下架(2)
#### Scenario: 状态未变化
- **WHEN** 管理员设置的上架状态与当前状态相同
- **THEN** 系统正常返回成功,不产生错误
---
### Requirement: Package 模型新增字段
系统 MUST 在 Package 模型中新增以下字段:
- `suggested_cost_price`:建议成本价(分为单位),默认 0
- `suggested_retail_price`:建议售价(分为单位),默认 0
- `shelf_status`上架状态1-上架 2-下架,默认 2
#### Scenario: 创建套餐时设置建议价格
- **WHEN** 管理员创建套餐并设置建议成本价和建议售价
- **THEN** 系统保存这些价格信息
#### Scenario: 查询套餐时返回建议价格
- **WHEN** 管理员查询套餐详情或列表
- **THEN** 响应中包含 suggested_cost_price、suggested_retail_price、shelf_status 字段
---
### Requirement: 清理废弃模型
系统 MUST 删除以下废弃的分佣相关模型和对应的数据库表:
- `AgentHierarchy` (tb_agent_hierarchy)
- `CommissionRule` (tb_commission_rule)
- `CommissionLadder` (tb_commission_ladder)
- `CommissionCombinedCondition` (tb_commission_combined_condition)
- `CommissionApproval` (tb_commission_approval)
- `CommissionTemplate` (tb_commission_template)
- `CarrierSettlement` (tb_carrier_settlement)
- `AgentPackageAllocation` (tb_agent_package_allocation)
#### Scenario: 迁移后废弃表不存在
- **WHEN** 执行数据库迁移后
- **THEN** 上述 8 个表在数据库中不再存在
#### Scenario: 代码中无废弃模型引用
- **WHEN** 删除模型定义后
- **THEN** 项目能够正常编译,无编译错误

View File

@@ -0,0 +1,99 @@
## ADDED Requirements
### Requirement: 创建套餐系列
系统 SHALL 允许平台管理员创建套餐系列,包含系列编码、系列名称、描述信息。系列编码 MUST 全局唯一(排除已删除记录)。新创建的套餐系列默认为启用状态。
#### Scenario: 成功创建套餐系列
- **WHEN** 管理员提交有效的套餐系列信息(系列编码、系列名称)
- **THEN** 系统创建套餐系列记录,返回创建的套餐系列详情,状态为启用(1)
#### Scenario: 系列编码重复
- **WHEN** 管理员提交的系列编码已存在(未删除)
- **THEN** 系统返回错误 "系列编码已存在"
#### Scenario: 缺少必填字段
- **WHEN** 管理员未提供系列编码或系列名称
- **THEN** 系统返回参数验证错误
---
### Requirement: 查询套餐系列列表
系统 SHALL 提供套餐系列列表查询功能,支持按系列名称模糊搜索、按状态筛选。结果 MUST 分页返回,按创建时间倒序排列。
#### Scenario: 查询所有套餐系列
- **WHEN** 管理员请求套餐系列列表,不带筛选条件
- **THEN** 系统返回所有未删除的套餐系列,分页显示
#### Scenario: 按名称搜索
- **WHEN** 管理员提供系列名称关键字
- **THEN** 系统返回名称包含该关键字的套餐系列
#### Scenario: 按状态筛选
- **WHEN** 管理员指定状态筛选(启用/禁用)
- **THEN** 系统只返回匹配状态的套餐系列
---
### Requirement: 查询套餐系列详情
系统 SHALL 允许管理员查询单个套餐系列的详细信息。
#### Scenario: 查询存在的套餐系列
- **WHEN** 管理员请求指定 ID 的套餐系列详情
- **THEN** 系统返回该套餐系列的完整信息
#### Scenario: 查询不存在的套餐系列
- **WHEN** 管理员请求不存在或已删除的套餐系列 ID
- **THEN** 系统返回 "套餐系列不存在" 错误
---
### Requirement: 更新套餐系列
系统 SHALL 允许管理员更新套餐系列的基本信息(系列名称、描述)。系列编码创建后 MUST NOT 允许修改。
#### Scenario: 成功更新套餐系列
- **WHEN** 管理员提交有效的更新信息
- **THEN** 系统更新套餐系列记录,返回更新后的详情
#### Scenario: 尝试修改系列编码
- **WHEN** 管理员尝试修改系列编码
- **THEN** 系统忽略系列编码字段,不进行修改
#### Scenario: 更新不存在的套餐系列
- **WHEN** 管理员更新不存在的套餐系列
- **THEN** 系统返回 "套餐系列不存在" 错误
---
### Requirement: 删除套餐系列
系统 SHALL 允许管理员删除套餐系列(软删除)。
#### Scenario: 成功删除套餐系列
- **WHEN** 管理员删除指定的套餐系列
- **THEN** 系统软删除该记录,后续查询不再返回
#### Scenario: 删除不存在的套餐系列
- **WHEN** 管理员删除不存在的套餐系列
- **THEN** 系统返回 "套餐系列不存在" 错误
---
### Requirement: 启用/禁用套餐系列
系统 SHALL 允许管理员切换套餐系列的启用状态。
#### Scenario: 启用套餐系列
- **WHEN** 管理员将禁用的套餐系列设置为启用
- **THEN** 系统更新状态为启用(1)
#### Scenario: 禁用套餐系列
- **WHEN** 管理员将启用的套餐系列设置为禁用
- **THEN** 系统更新状态为禁用(2)
#### Scenario: 状态未变化
- **WHEN** 管理员设置的状态与当前状态相同
- **THEN** 系统正常返回成功,不产生错误

View File

@@ -0,0 +1,128 @@
## 1. 模型清理
- [x] 1.1 删除 `internal/model/commission.go` 中的废弃模型AgentHierarchy, CommissionRule, CommissionLadder, CommissionCombinedCondition, CommissionApproval, CommissionTemplate, CarrierSettlement
- [x] 1.2 删除 `internal/model/package.go` 中的 `AgentPackageAllocation` 模型
- [x] 1.3 执行 `go build ./...` 确认无编译错误,如有引用则同步清理
## 2. Package 模型调整
- [x] 2.1 在 `internal/model/package.go` 的 Package 结构体中新增 `suggested_cost_price` 字段bigint, 默认 0, 注释:建议成本价)
- [x] 2.2 在 Package 结构体中新增 `suggested_retail_price` 字段bigint, 默认 0, 注释:建议售价)
- [x] 2.3 在 Package 结构体中新增 `shelf_status` 字段int, 默认 2, 注释:上架状态 1-上架 2-下架)
## 3. 数据库迁移
- [x] 3.1 创建迁移文件UP 脚本删除 8 个废弃表tb_agent_hierarchy, tb_commission_rule, tb_commission_ladder, tb_commission_combined_condition, tb_commission_approval, tb_commission_template, tb_carrier_settlement, tb_agent_package_allocation
- [x] 3.2 在迁移 UP 脚本中添加 tb_package 表的 3 个新字段
- [x] 3.3 编写迁移 DOWN 脚本(重建表结构、删除新字段)
- [x] 3.4 本地执行迁移验证
## 4. 套餐系列 DTO
- [x] 4.1 创建 `internal/model/dto/package_series.go`,定义 CreatePackageSeriesRequestseries_code 必填, series_name 必填, description 可选)
- [x] 4.2 定义 UpdatePackageSeriesRequestseries_name, description
- [x] 4.3 定义 PackageSeriesListRequestpage, page_size, series_name 模糊, status 筛选)
- [x] 4.4 定义 UpdatePackageSeriesStatusRequeststatus 必填)
- [x] 4.5 定义 PackageSeriesResponse 响应结构
## 5. 套餐系列 Store
- [x] 5.1 创建 `internal/store/postgres/package_series_store.go`,实现 Create 方法
- [x] 5.2 实现 GetByID 方法
- [x] 5.3 实现 GetByCode 方法(用于编码唯一性检查)
- [x] 5.4 实现 Update 方法
- [x] 5.5 实现 Delete 方法(软删除)
- [x] 5.6 实现 List 方法(支持分页、名称模糊搜索、状态筛选)
- [x] 5.7 实现 UpdateStatus 方法
## 6. 套餐系列 Service
- [x] 6.1 创建 `internal/service/package_series/service.go`,实现 Create 方法(检查编码唯一性)
- [x] 6.2 实现 Get 方法
- [x] 6.3 实现 Update 方法(忽略编码修改)
- [x] 6.4 实现 Delete 方法
- [x] 6.5 实现 List 方法
- [x] 6.6 实现 UpdateStatus 方法
## 7. 套餐系列 Handler
- [x] 7.1 创建 `internal/handler/admin/package_series.go`,实现 Create 接口
- [x] 7.2 实现 Get 接口
- [x] 7.3 实现 Update 接口
- [x] 7.4 实现 Delete 接口
- [x] 7.5 实现 List 接口
- [x] 7.6 实现 UpdateStatus 接口
## 8. 套餐 DTO
- [x] 8.1 创建 `internal/model/dto/package.go`,定义 CreatePackageRequestpackage_code 必填, package_name 必填, series_id, package_type 必填, duration_months 必填, data_type, real_data_mb, virtual_data_mb, data_amount_mb, price 必填, suggested_cost_price, suggested_retail_price
- [x] 8.2 定义 UpdatePackageRequest除 package_code 外的字段)
- [x] 8.3 定义 PackageListRequestpage, page_size, package_name 模糊, series_id, status, shelf_status, package_type
- [x] 8.4 定义 UpdatePackageStatusRequeststatus 必填)
- [x] 8.5 定义 UpdatePackageShelfStatusRequestshelf_status 必填)
- [x] 8.6 定义 PackageResponse 响应结构(包含新增的 3 个字段)
## 9. 套餐 Store
- [x] 9.1 创建 `internal/store/postgres/package_store.go`,实现 Create 方法
- [x] 9.2 实现 GetByID 方法
- [x] 9.3 实现 GetByCode 方法
- [x] 9.4 实现 Update 方法
- [x] 9.5 实现 Delete 方法
- [x] 9.6 实现 List 方法(支持分页、名称模糊、系列筛选、状态筛选、上架状态筛选、类型筛选)
- [x] 9.7 实现 UpdateStatus 方法
- [x] 9.8 实现 UpdateShelfStatus 方法
## 10. 套餐 Service
- [x] 10.1 创建 `internal/service/package/service.go`,实现 Create 方法(检查编码唯一性、验证系列存在)
- [x] 10.2 实现 Get 方法
- [x] 10.3 实现 Update 方法(忽略编码修改、验证系列存在)
- [x] 10.4 实现 Delete 方法
- [x] 10.5 实现 List 方法
- [x] 10.6 实现 UpdateStatus 方法(禁用时强制下架)
- [x] 10.7 实现 UpdateShelfStatus 方法(检查启用状态才能上架)
## 11. 套餐 Handler
- [x] 11.1 创建 `internal/handler/admin/package.go`,实现 Create 接口
- [x] 11.2 实现 Get 接口
- [x] 11.3 实现 Update 接口
- [x] 11.4 实现 Delete 接口
- [x] 11.5 实现 List 接口
- [x] 11.6 实现 UpdateStatus 接口
- [x] 11.7 实现 UpdateShelfStatus 接口
## 12. Bootstrap 注册
- [x] 12.1 在 `internal/bootstrap/stores.go` 中注册 PackageSeriesStore 和 PackageStore
- [x] 12.2 在 `internal/bootstrap/services.go` 中注册 PackageSeriesService 和 PackageService
- [x] 12.3 在 `internal/bootstrap/handlers.go` 中注册 PackageSeriesHandler 和 PackageHandler
## 13. 路由注册
- [x] 13.1 在 `internal/router/` 中注册套餐系列路由组 `/api/admin/package-series`POST, GET, GET/:id, PUT/:id, DELETE/:id, PATCH/:id/status
- [x] 13.2 注册套餐路由组 `/api/admin/packages`POST, GET, GET/:id, PUT/:id, DELETE/:id, PATCH/:id/status, PATCH/:id/shelf
## 14. 文档生成器更新
- [x] 14.1 在 `cmd/api/docs.go` 中添加 PackageSeriesHandler 和 PackageHandler
- [x] 14.2 在 `cmd/gendocs/main.go` 中添加 PackageSeriesHandler 和 PackageHandler
- [x] 14.3 执行 `go run cmd/gendocs/main.go` 生成 OpenAPI 文档
## 15. 测试
- [x] 15.1 为 PackageSeriesStore 编写单元测试
- [x] 15.2 为 PackageStore 编写单元测试
- [x] 15.3 为 PackageSeriesService 编写单元测试(覆盖编码唯一性检查)
- [x] 15.4 为 PackageService 编写单元测试(覆盖双状态逻辑)
- [x] 15.5 编写套餐系列 API 集成测试
- [x] 15.6 编写套餐 API 集成测试(覆盖禁用强制下架、禁用不能上架场景)
- [x] 15.7 执行 `go test ./...` 确认所有测试通过
## 16. 最终验证
- [x] 16.1 执行 `go build ./...` 确认编译通过
- [x] 16.2 执行 `go vet ./...` 检查代码质量
- [x] 16.3 启动服务,手动测试 API 接口
- [x] 16.4 确认 OpenAPI 文档正确生成

View File

@@ -0,0 +1,180 @@
## ADDED Requirements
### Requirement: 创建套餐
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、流量配置、价格和建议价格。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
#### Scenario: 成功创建套餐
- **WHEN** 管理员提交有效的套餐信息
- **THEN** 系统创建套餐记录,状态为启用(1),上架状态为下架(2),返回创建的套餐详情
#### Scenario: 套餐编码重复
- **WHEN** 管理员提交的套餐编码已存在(未删除)
- **THEN** 系统返回错误 "套餐编码已存在"
#### Scenario: 关联不存在的套餐系列
- **WHEN** 管理员指定的系列 ID 不存在
- **THEN** 系统返回错误 "套餐系列不存在"
#### Scenario: 缺少必填字段
- **WHEN** 管理员未提供必填字段(套餐编码、套餐名称、套餐类型、时长、价格)
- **THEN** 系统返回参数验证错误
---
### Requirement: 查询套餐列表
系统 SHALL 提供套餐列表查询功能,支持按套餐名称模糊搜索、按系列 ID 筛选、按状态筛选、按上架状态筛选、按套餐类型筛选。结果 MUST 分页返回,按创建时间倒序排列。
#### Scenario: 查询所有套餐
- **WHEN** 管理员请求套餐列表,不带筛选条件
- **THEN** 系统返回所有未删除的套餐,分页显示
#### Scenario: 按系列筛选
- **WHEN** 管理员指定套餐系列 ID
- **THEN** 系统只返回属于该系列的套餐
#### Scenario: 按名称搜索
- **WHEN** 管理员提供套餐名称关键字
- **THEN** 系统返回名称包含该关键字的套餐
#### Scenario: 按状态筛选
- **WHEN** 管理员指定启用状态
- **THEN** 系统只返回匹配启用状态的套餐
#### Scenario: 按上架状态筛选
- **WHEN** 管理员指定上架状态
- **THEN** 系统只返回匹配上架状态的套餐
#### Scenario: 按套餐类型筛选
- **WHEN** 管理员指定套餐类型formal/addon
- **THEN** 系统只返回匹配类型的套餐
---
### Requirement: 查询套餐详情
系统 SHALL 允许管理员查询单个套餐的详细信息。
#### Scenario: 查询存在的套餐
- **WHEN** 管理员请求指定 ID 的套餐详情
- **THEN** 系统返回该套餐的完整信息
#### Scenario: 查询不存在的套餐
- **WHEN** 管理员请求不存在或已删除的套餐 ID
- **THEN** 系统返回 "套餐不存在" 错误
---
### Requirement: 更新套餐
系统 SHALL 允许管理员更新套餐的基本信息。套餐编码创建后 MUST NOT 允许修改。
#### Scenario: 成功更新套餐
- **WHEN** 管理员提交有效的更新信息
- **THEN** 系统更新套餐记录,返回更新后的详情
#### Scenario: 尝试修改套餐编码
- **WHEN** 管理员尝试修改套餐编码
- **THEN** 系统忽略套餐编码字段,不进行修改
#### Scenario: 更新不存在的套餐
- **WHEN** 管理员更新不存在的套餐
- **THEN** 系统返回 "套餐不存在" 错误
#### Scenario: 关联不存在的套餐系列
- **WHEN** 管理员将套餐关联到不存在的系列
- **THEN** 系统返回错误 "套餐系列不存在"
---
### Requirement: 删除套餐
系统 SHALL 允许管理员删除套餐(软删除)。
#### Scenario: 成功删除套餐
- **WHEN** 管理员删除指定的套餐
- **THEN** 系统软删除该记录,后续查询不再返回
#### Scenario: 删除不存在的套餐
- **WHEN** 管理员删除不存在的套餐
- **THEN** 系统返回 "套餐不存在" 错误
---
### Requirement: 启用/禁用套餐
系统 SHALL 允许管理员切换套餐的启用状态。禁用套餐时 MUST 同时将上架状态设置为下架。
#### Scenario: 启用套餐
- **WHEN** 管理员将禁用的套餐设置为启用
- **THEN** 系统更新状态为启用(1),上架状态保持不变
#### Scenario: 禁用套餐
- **WHEN** 管理员将启用的套餐设置为禁用
- **THEN** 系统更新状态为禁用(2),同时将上架状态设置为下架(2)
#### Scenario: 禁用已上架的套餐
- **WHEN** 管理员禁用一个当前已上架的套餐
- **THEN** 系统更新状态为禁用(2),上架状态强制设置为下架(2)
---
### Requirement: 上架/下架套餐
系统 SHALL 允许管理员切换套餐的上架状态。只有启用状态的套餐才能上架。
#### Scenario: 上架启用的套餐
- **WHEN** 管理员将启用且下架的套餐设置为上架
- **THEN** 系统更新上架状态为上架(1)
#### Scenario: 尝试上架禁用的套餐
- **WHEN** 管理员尝试上架一个禁用的套餐
- **THEN** 系统返回错误 "禁用的套餐不能上架,请先启用"
#### Scenario: 下架套餐
- **WHEN** 管理员将上架的套餐设置为下架
- **THEN** 系统更新上架状态为下架(2)
#### Scenario: 状态未变化
- **WHEN** 管理员设置的上架状态与当前状态相同
- **THEN** 系统正常返回成功,不产生错误
---
### Requirement: Package 模型新增字段
系统 MUST 在 Package 模型中新增以下字段:
- `suggested_cost_price`:建议成本价(分为单位),默认 0
- `suggested_retail_price`:建议售价(分为单位),默认 0
- `shelf_status`上架状态1-上架 2-下架,默认 2
#### Scenario: 创建套餐时设置建议价格
- **WHEN** 管理员创建套餐并设置建议成本价和建议售价
- **THEN** 系统保存这些价格信息
#### Scenario: 查询套餐时返回建议价格
- **WHEN** 管理员查询套餐详情或列表
- **THEN** 响应中包含 suggested_cost_price、suggested_retail_price、shelf_status 字段
---
### Requirement: 清理废弃模型
系统 MUST 删除以下废弃的分佣相关模型和对应的数据库表:
- `AgentHierarchy` (tb_agent_hierarchy)
- `CommissionRule` (tb_commission_rule)
- `CommissionLadder` (tb_commission_ladder)
- `CommissionCombinedCondition` (tb_commission_combined_condition)
- `CommissionApproval` (tb_commission_approval)
- `CommissionTemplate` (tb_commission_template)
- `CarrierSettlement` (tb_carrier_settlement)
- `AgentPackageAllocation` (tb_agent_package_allocation)
#### Scenario: 迁移后废弃表不存在
- **WHEN** 执行数据库迁移后
- **THEN** 上述 8 个表在数据库中不再存在
#### Scenario: 代码中无废弃模型引用
- **WHEN** 删除模型定义后
- **THEN** 项目能够正常编译,无编译错误

View File

@@ -0,0 +1,99 @@
## ADDED Requirements
### Requirement: 创建套餐系列
系统 SHALL 允许平台管理员创建套餐系列,包含系列编码、系列名称、描述信息。系列编码 MUST 全局唯一(排除已删除记录)。新创建的套餐系列默认为启用状态。
#### Scenario: 成功创建套餐系列
- **WHEN** 管理员提交有效的套餐系列信息(系列编码、系列名称)
- **THEN** 系统创建套餐系列记录,返回创建的套餐系列详情,状态为启用(1)
#### Scenario: 系列编码重复
- **WHEN** 管理员提交的系列编码已存在(未删除)
- **THEN** 系统返回错误 "系列编码已存在"
#### Scenario: 缺少必填字段
- **WHEN** 管理员未提供系列编码或系列名称
- **THEN** 系统返回参数验证错误
---
### Requirement: 查询套餐系列列表
系统 SHALL 提供套餐系列列表查询功能,支持按系列名称模糊搜索、按状态筛选。结果 MUST 分页返回,按创建时间倒序排列。
#### Scenario: 查询所有套餐系列
- **WHEN** 管理员请求套餐系列列表,不带筛选条件
- **THEN** 系统返回所有未删除的套餐系列,分页显示
#### Scenario: 按名称搜索
- **WHEN** 管理员提供系列名称关键字
- **THEN** 系统返回名称包含该关键字的套餐系列
#### Scenario: 按状态筛选
- **WHEN** 管理员指定状态筛选(启用/禁用)
- **THEN** 系统只返回匹配状态的套餐系列
---
### Requirement: 查询套餐系列详情
系统 SHALL 允许管理员查询单个套餐系列的详细信息。
#### Scenario: 查询存在的套餐系列
- **WHEN** 管理员请求指定 ID 的套餐系列详情
- **THEN** 系统返回该套餐系列的完整信息
#### Scenario: 查询不存在的套餐系列
- **WHEN** 管理员请求不存在或已删除的套餐系列 ID
- **THEN** 系统返回 "套餐系列不存在" 错误
---
### Requirement: 更新套餐系列
系统 SHALL 允许管理员更新套餐系列的基本信息(系列名称、描述)。系列编码创建后 MUST NOT 允许修改。
#### Scenario: 成功更新套餐系列
- **WHEN** 管理员提交有效的更新信息
- **THEN** 系统更新套餐系列记录,返回更新后的详情
#### Scenario: 尝试修改系列编码
- **WHEN** 管理员尝试修改系列编码
- **THEN** 系统忽略系列编码字段,不进行修改
#### Scenario: 更新不存在的套餐系列
- **WHEN** 管理员更新不存在的套餐系列
- **THEN** 系统返回 "套餐系列不存在" 错误
---
### Requirement: 删除套餐系列
系统 SHALL 允许管理员删除套餐系列(软删除)。
#### Scenario: 成功删除套餐系列
- **WHEN** 管理员删除指定的套餐系列
- **THEN** 系统软删除该记录,后续查询不再返回
#### Scenario: 删除不存在的套餐系列
- **WHEN** 管理员删除不存在的套餐系列
- **THEN** 系统返回 "套餐系列不存在" 错误
---
### Requirement: 启用/禁用套餐系列
系统 SHALL 允许管理员切换套餐系列的启用状态。
#### Scenario: 启用套餐系列
- **WHEN** 管理员将禁用的套餐系列设置为启用
- **THEN** 系统更新状态为启用(1)
#### Scenario: 禁用套餐系列
- **WHEN** 管理员将启用的套餐系列设置为禁用
- **THEN** 系统更新状态为禁用(2)
#### Scenario: 状态未变化
- **WHEN** 管理员设置的状态与当前状态相同
- **THEN** 系统正常返回成功,不产生错误