feat: 实现套餐管理模块,包含套餐系列、双状态管理、废弃模型清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m24s
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:
252
openspec/changes/add-one-time-commission/design.md
Normal file
252
openspec/changes/add-one-time-commission/design.md
Normal 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. **一次性佣金发放给谁?**
|
||||
- 当前设计:发放给卡/设备的直接归属店铺
|
||||
- 待确认:是否需要多级分佣?
|
||||
Reference in New Issue
Block a user