Files
junhong_cmp_fiber/openspec/changes/add-one-time-commission/design.md
huang 79c061b6fa
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m24s
feat: 实现套餐管理模块,包含套餐系列、双状态管理、废弃模型清理
- 新增套餐系列管理 (CRUD + 状态切换)
- 新增套餐管理 (CRUD + 启用/禁用 + 上架/下架双状态)
- 清理 8 个废弃分佣模型及对应数据库表
- Package 模型新增建议成本价、建议售价、上架状态字段
- 完整的 Store/Service/Handler 三层实现
- 包含单元测试和集成测试
- 归档 add-package-module change
- 新增多个 OpenSpec changes (订单支付、店铺套餐分配、一次性分佣、卡设备系列绑定)
2026-01-27 19:55:47 +08:00

253 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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. **一次性佣金发放给谁?**
- 当前设计:发放给卡/设备的直接归属店铺
- 待确认:是否需要多级分佣?