feat: 实现一次性佣金功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m41s

- 新增佣金计算服务,支持一次性佣金和返佣计算
- 新增 ShopSeriesOneTimeCommissionTier 模型和存储层
- 新增两个数据库迁移:一次性佣金表和订单佣金字段
- 更新 Commission 模型,新增佣金来源和关联字段
- 更新 CommissionRecord 存储层,支持一次性佣金查询
- 更新 MyCommission 服务,集成一次性佣金计算逻辑
- 更新 ShopCommission 服务,支持一次性佣金统计
- 新增佣金计算异步任务处理器
- 更新 API 路由,新增一次性佣金相关端点
- 归档 OpenSpec 变更文档,同步规范到主规范库
This commit is contained in:
2026-01-29 09:36:12 +08:00
parent dfcf16f548
commit e87513541b
33 changed files with 1668 additions and 270 deletions

View File

@@ -0,0 +1,470 @@
package commission_calculation
import (
"context"
"fmt"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
commissionRecordStore *postgres.CommissionRecordStore
shopStore *postgres.ShopStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
shopSeriesOneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
walletStore *postgres.WalletStore
walletTransactionStore *postgres.WalletTransactionStore
orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore
packageStore *postgres.PackageStore
commissionStatsService *commission_stats.Service
logger *zap.Logger
}
func New(
db *gorm.DB,
commissionRecordStore *postgres.CommissionRecordStore,
shopStore *postgres.ShopStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
shopSeriesOneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
walletStore *postgres.WalletStore,
walletTransactionStore *postgres.WalletTransactionStore,
orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore,
packageStore *postgres.PackageStore,
commissionStatsService *commission_stats.Service,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
commissionRecordStore: commissionRecordStore,
shopStore: shopStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
shopSeriesOneTimeCommissionTierStore: shopSeriesOneTimeCommissionTierStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
walletStore: walletStore,
walletTransactionStore: walletTransactionStore,
orderStore: orderStore,
orderItemStore: orderItemStore,
packageStore: packageStore,
commissionStatsService: commissionStatsService,
logger: logger,
}
}
func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取订单失败")
}
if order.CommissionStatus == model.CommissionStatusCalculated {
s.logger.Warn("订单佣金已计算,跳过", zap.String("order_id", fmt.Sprint(orderID)))
return nil
}
costDiffRecords, err := s.CalculateCostDiffCommission(ctx, order)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "计算成本价差佣金失败")
}
for _, record := range costDiffRecords {
if err := s.commissionRecordStore.Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建成本价差佣金记录失败")
}
if err := s.CreditCommission(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "佣金入账失败")
}
}
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
if err := s.TriggerOneTimeCommissionForCard(ctx, order, *order.IotCardID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "触发单卡一次性佣金失败")
}
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
if err := s.TriggerOneTimeCommissionForDevice(ctx, order, *order.DeviceID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "触发设备一次性佣金失败")
}
}
order.CommissionStatus = model.CommissionStatusCalculated
if err := s.orderStore.Update(ctx, order); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金状态失败")
}
return nil
})
}
func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.Order) ([]*model.CommissionRecord, error) {
var records []*model.CommissionRecord
if order.SellerShopID == nil {
return records, nil
}
sellerShop, err := s.shopStore.GetByID(ctx, *order.SellerShopID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取销售店铺失败")
}
sellerProfit := order.TotalAmount - order.SellerCostPrice
if sellerProfit > 0 {
records = append(records, &model.CommissionRecord{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Updater,
},
ShopID: *order.SellerShopID,
OrderID: order.ID,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
CommissionSource: model.CommissionSourceCostDiff,
Amount: sellerProfit,
Status: model.CommissionStatusReleased,
})
}
childCostPrice := order.SellerCostPrice
currentShopID := sellerShop.ParentID
for currentShopID != nil && *currentShopID > 0 {
currentShop, err := s.shopStore.GetByID(ctx, *currentShopID)
if err != nil {
s.logger.Error("获取上级店铺失败", zap.Uint("shop_id", *currentShopID), zap.Error(err))
break
}
allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, currentShop.ID, *order.SeriesID)
if err != nil {
s.logger.Warn("上级店铺未分配该系列,跳过", zap.Uint("shop_id", currentShop.ID), zap.Uint("series_id", *order.SeriesID))
break
}
myCostPrice := s.calculateCostPrice(allocation, order.TotalAmount)
profit := childCostPrice - myCostPrice
if profit > 0 {
records = append(records, &model.CommissionRecord{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Updater,
},
ShopID: currentShop.ID,
OrderID: order.ID,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
CommissionSource: model.CommissionSourceCostDiff,
Amount: profit,
Status: model.CommissionStatusReleased,
})
}
childCostPrice = myCostPrice
currentShopID = currentShop.ParentID
}
return records, nil
}
func (s *Service) calculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
if allocation.BaseCommissionMode == model.CommissionModeFixed {
return orderAmount - allocation.BaseCommissionValue
} else if allocation.BaseCommissionMode == model.CommissionModePercent {
commission := orderAmount * allocation.BaseCommissionValue / 1000
return orderAmount - commission
}
return orderAmount
}
func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *model.Order, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取卡信息失败")
}
if card.FirstCommissionPaid {
return nil
}
if card.SeriesAllocationID == nil {
return nil
}
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取系列分配失败")
}
if !allocation.EnableOneTimeCommission {
return nil
}
var rechargeAmount int64
switch allocation.OneTimeCommissionTrigger {
case model.OneTimeCommissionTriggerSingleRecharge:
rechargeAmount = order.TotalAmount
case model.OneTimeCommissionTriggerAccumulatedRecharge:
rechargeAmount = card.AccumulatedRecharge + order.TotalAmount
default:
return nil
}
if rechargeAmount < allocation.OneTimeCommissionThreshold {
return nil
}
commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "计算一次性佣金失败")
}
if commissionAmount <= 0 {
return nil
}
if card.ShopID == nil {
return errors.New(errors.CodeInvalidParam, "卡未归属任何店铺,无法发放佣金")
}
record := &model.CommissionRecord{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Updater,
},
ShopID: *card.ShopID,
OrderID: order.ID,
IotCardID: &cardID,
CommissionSource: model.CommissionSourceOneTime,
Amount: commissionAmount,
Status: model.CommissionStatusReleased,
Remark: "一次性佣金",
}
if err := s.commissionRecordStore.Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
}
if err := s.CreditCommission(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
}
card.FirstCommissionPaid = true
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
card.AccumulatedRecharge = rechargeAmount
}
if err := s.iotCardStore.Update(ctx, card); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
}
return nil
}
func (s *Service) TriggerOneTimeCommissionForDevice(ctx context.Context, order *model.Order, deviceID uint) error {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取设备信息失败")
}
if device.FirstCommissionPaid {
return nil
}
if device.SeriesAllocationID == nil {
return nil
}
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *device.SeriesAllocationID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取系列分配失败")
}
if !allocation.EnableOneTimeCommission {
return nil
}
var rechargeAmount int64
switch allocation.OneTimeCommissionTrigger {
case model.OneTimeCommissionTriggerSingleRecharge:
rechargeAmount = order.TotalAmount
case model.OneTimeCommissionTriggerAccumulatedRecharge:
rechargeAmount = device.AccumulatedRecharge + order.TotalAmount
default:
return nil
}
if rechargeAmount < allocation.OneTimeCommissionThreshold {
return nil
}
commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "计算一次性佣金失败")
}
if commissionAmount <= 0 {
return nil
}
if device.ShopID == nil {
return errors.New(errors.CodeInvalidParam, "设备未归属任何店铺,无法发放佣金")
}
record := &model.CommissionRecord{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Updater,
},
ShopID: *device.ShopID,
OrderID: order.ID,
DeviceID: &deviceID,
CommissionSource: model.CommissionSourceOneTime,
Amount: commissionAmount,
Status: model.CommissionStatusReleased,
Remark: "一次性佣金(设备)",
}
if err := s.commissionRecordStore.Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败")
}
if err := s.CreditCommission(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败")
}
device.FirstCommissionPaid = true
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
device.AccumulatedRecharge = rechargeAmount
}
if err := s.deviceStore.Update(ctx, device); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备状态失败")
}
return nil
}
func (s *Service) calculateOneTimeCommission(ctx context.Context, allocation *model.ShopSeriesAllocation, orderAmount int64) (int64, error) {
switch allocation.OneTimeCommissionType {
case model.OneTimeCommissionTypeFixed:
return s.calculateFixedCommission(allocation.OneTimeCommissionMode, allocation.OneTimeCommissionValue, orderAmount), nil
case model.OneTimeCommissionTypeTiered:
return s.calculateTieredCommission(ctx, allocation.ID, orderAmount)
}
return 0, nil
}
func (s *Service) calculateFixedCommission(mode string, value int64, orderAmount int64) int64 {
if mode == model.CommissionModeFixed {
return value
} else if mode == model.CommissionModePercent {
return orderAmount * value / 1000
}
return 0
}
func (s *Service) calculateTieredCommission(ctx context.Context, allocationID uint, orderAmount int64) (int64, error) {
tiers, err := s.shopSeriesOneTimeCommissionTierStore.ListByAllocationID(ctx, allocationID)
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "获取梯度配置失败")
}
if len(tiers) == 0 {
return 0, nil
}
stats, err := s.commissionStatsService.GetCurrentStats(ctx, allocationID, "all_time")
if err != nil {
s.logger.Error("获取销售业绩统计失败", zap.Uint("allocation_id", allocationID), zap.Error(err))
return 0, nil
}
if stats == nil {
return 0, nil
}
var matchedTier *model.ShopSeriesOneTimeCommissionTier
for _, tier := range tiers {
var salesValue int64
if tier.TierType == model.TierTypeSalesCount {
salesValue = stats.TotalSalesCount
} else if tier.TierType == model.TierTypeSalesAmount {
salesValue = stats.TotalSalesAmount
} else {
continue
}
if salesValue >= tier.ThresholdValue {
if matchedTier == nil || tier.ThresholdValue > matchedTier.ThresholdValue {
matchedTier = tier
}
}
}
if matchedTier == nil {
return 0, nil
}
if matchedTier.CommissionMode == model.CommissionModeFixed {
return matchedTier.CommissionValue, nil
} else if matchedTier.CommissionMode == model.CommissionModePercent {
return orderAmount * matchedTier.CommissionValue / 1000, nil
}
return 0, nil
}
func (s *Service) CreditCommission(ctx context.Context, record *model.CommissionRecord) error {
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, "shop", record.ShopID, "commission")
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺钱包失败")
}
wallet.Balance += record.Amount
if err := s.db.WithContext(ctx).Model(wallet).Update("balance", wallet.Balance).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新钱包余额失败")
}
now := time.Now()
record.BalanceAfter = wallet.Balance
record.Status = model.CommissionStatusReleased
record.ReleasedAt = &now
if err := s.db.WithContext(ctx).Model(record).Updates(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
}
remark := "佣金入账"
transaction := &model.WalletTransaction{
WalletID: wallet.ID,
UserID: record.Creator,
TransactionType: "commission",
Amount: record.Amount,
BalanceBefore: wallet.Balance - record.Amount,
BalanceAfter: wallet.Balance,
Status: 1,
ReferenceType: stringPtr("commission"),
ReferenceID: &record.ID,
Remark: &remark,
Creator: record.Creator,
}
if err := s.walletTransactionStore.Create(ctx, transaction); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
return nil
}
func stringPtr(s string) *string {
return &s
}

View File

@@ -329,8 +329,8 @@ func (s *Service) ListMyCommissionRecords(ctx context.Context, req *dto.MyCommis
query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
Where("shop_id = ?", shopID)
if req.CommissionType != nil {
query = query.Where("commission_type = ?", *req.CommissionType)
if req.CommissionSource != nil {
query = query.Where("commission_source = ?", *req.CommissionSource)
}
var total int64
@@ -347,14 +347,14 @@ func (s *Service) ListMyCommissionRecords(ctx context.Context, req *dto.MyCommis
items := make([]dto.MyCommissionRecordItem, 0, len(records))
for _, r := range records {
items = append(items, dto.MyCommissionRecordItem{
ID: r.ID,
ShopID: r.ShopID,
OrderID: r.OrderID,
CommissionType: r.CommissionType,
Amount: r.Amount,
Status: r.Status,
StatusName: getCommissionStatusName(r.Status),
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
ID: r.ID,
ShopID: r.ShopID,
OrderID: r.OrderID,
CommissionSource: r.CommissionSource,
Amount: r.Amount,
Status: r.Status,
StatusName: getCommissionStatusName(r.Status),
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
@@ -366,6 +366,97 @@ func (s *Service) ListMyCommissionRecords(ctx context.Context, req *dto.MyCommis
}, nil
}
func (s *Service) GetStats(ctx context.Context, req *dto.CommissionStatsRequest) (*dto.CommissionStatsResponse, error) {
shopID, err := s.getShopIDFromContext(ctx)
if err != nil {
return nil, err
}
filters := &postgres.CommissionRecordListFilters{
ShopID: shopID,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
stats, err := s.commissionRecordStore.GetStats(ctx, filters)
if err != nil {
return nil, fmt.Errorf("获取佣金统计失败: %w", err)
}
if stats == nil {
return &dto.CommissionStatsResponse{}, nil
}
var costDiffPercent, oneTimePercent, tierBonusPercent int64
if stats.TotalAmount > 0 {
costDiffPercent = stats.CostDiffAmount * 1000 / stats.TotalAmount
oneTimePercent = stats.OneTimeAmount * 1000 / stats.TotalAmount
tierBonusPercent = stats.TierBonusAmount * 1000 / stats.TotalAmount
}
return &dto.CommissionStatsResponse{
TotalAmount: stats.TotalAmount,
CostDiffAmount: stats.CostDiffAmount,
OneTimeAmount: stats.OneTimeAmount,
TierBonusAmount: stats.TierBonusAmount,
CostDiffPercent: costDiffPercent,
OneTimePercent: oneTimePercent,
TierBonusPercent: tierBonusPercent,
TotalCount: stats.TotalCount,
CostDiffCount: stats.CostDiffCount,
OneTimeCount: stats.OneTimeCount,
TierBonusCount: stats.TierBonusCount,
}, nil
}
func (s *Service) GetDailyStats(ctx context.Context, req *dto.DailyCommissionStatsRequest) ([]*dto.DailyCommissionStatsResponse, error) {
shopID, err := s.getShopIDFromContext(ctx)
if err != nil {
return nil, err
}
days := 30
if req.Days != nil && *req.Days > 0 {
days = *req.Days
}
filters := &postgres.CommissionRecordListFilters{
ShopID: shopID,
StartTime: req.StartDate,
EndTime: req.EndDate,
}
dailyStats, err := s.commissionRecordStore.GetDailyStats(ctx, filters, days)
if err != nil {
return nil, fmt.Errorf("获取每日佣金统计失败: %w", err)
}
result := make([]*dto.DailyCommissionStatsResponse, 0, len(dailyStats))
for _, stat := range dailyStats {
result = append(result, &dto.DailyCommissionStatsResponse{
Date: stat.Date,
TotalAmount: stat.TotalAmount,
TotalCount: stat.TotalCount,
})
}
return result, nil
}
func (s *Service) getShopIDFromContext(ctx context.Context) (uint, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeAgent {
return 0, errors.New(errors.CodeForbidden, "仅代理商用户可访问")
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return 0, errors.New(errors.CodeForbidden, "无法获取店铺信息")
}
return shopID, nil
}
// generateWithdrawalNo 生成提现单号
func generateWithdrawalNo() string {
now := time.Now()

View File

@@ -345,11 +345,11 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
}
filters := &postgres.CommissionRecordListFilters{
ShopID: shopID,
CommissionType: req.CommissionType,
ICCID: req.ICCID,
DeviceNo: req.DeviceNo,
OrderNo: req.OrderNo,
ShopID: shopID,
CommissionSource: req.CommissionSource,
ICCID: req.ICCID,
DeviceNo: req.DeviceNo,
OrderNo: req.OrderNo,
}
records, total, err := s.commissionRecordStore.ListByShopID(ctx, opts, filters)
@@ -360,18 +360,18 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
items := make([]dto.ShopCommissionRecordItem, 0, len(records))
for _, r := range records {
item := dto.ShopCommissionRecordItem{
ID: r.ID,
Amount: r.Amount,
BalanceAfter: r.BalanceAfter,
CommissionType: r.CommissionType,
Status: r.Status,
StatusName: getCommissionStatusName(r.Status),
OrderID: r.OrderID,
OrderNo: "",
DeviceNo: "",
ICCID: "",
OrderCreatedAt: "",
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
ID: r.ID,
Amount: r.Amount,
BalanceAfter: r.BalanceAfter,
CommissionSource: r.CommissionSource,
Status: r.Status,
StatusName: getCommissionStatusName(r.Status),
OrderID: r.OrderID,
OrderNo: "",
DeviceNo: "",
ICCID: "",
OrderCreatedAt: "",
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
}
items = append(items, item)
}