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 (订单支付、店铺套餐分配、一次性分佣、卡设备系列绑定)
253 lines
7.3 KiB
Markdown
253 lines
7.3 KiB
Markdown
## 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. **一次性佣金发放给谁?**
|
||
- 当前设计:发放给卡/设备的直接归属店铺
|
||
- 待确认:是否需要多级分佣?
|