All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m41s
- 新增佣金计算服务,支持一次性佣金和返佣计算 - 新增 ShopSeriesOneTimeCommissionTier 模型和存储层 - 新增两个数据库迁移:一次性佣金表和订单佣金字段 - 更新 Commission 模型,新增佣金来源和关联字段 - 更新 CommissionRecord 存储层,支持一次性佣金查询 - 更新 MyCommission 服务,集成一次性佣金计算逻辑 - 更新 ShopCommission 服务,支持一次性佣金统计 - 新增佣金计算异步任务处理器 - 更新 API 路由,新增一次性佣金相关端点 - 归档 OpenSpec 变更文档,同步规范到主规范库
509 lines
16 KiB
Go
509 lines
16 KiB
Go
package my_commission
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Service struct {
|
|
db *gorm.DB
|
|
shopStore *postgres.ShopStore
|
|
walletStore *postgres.WalletStore
|
|
commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore
|
|
commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore
|
|
commissionRecordStore *postgres.CommissionRecordStore
|
|
walletTransactionStore *postgres.WalletTransactionStore
|
|
}
|
|
|
|
func New(
|
|
db *gorm.DB,
|
|
shopStore *postgres.ShopStore,
|
|
walletStore *postgres.WalletStore,
|
|
commissionWithdrawalRequestStore *postgres.CommissionWithdrawalRequestStore,
|
|
commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore,
|
|
commissionRecordStore *postgres.CommissionRecordStore,
|
|
walletTransactionStore *postgres.WalletTransactionStore,
|
|
) *Service {
|
|
return &Service{
|
|
db: db,
|
|
shopStore: shopStore,
|
|
walletStore: walletStore,
|
|
commissionWithdrawalRequestStore: commissionWithdrawalRequestStore,
|
|
commissionWithdrawalSettingStore: commissionWithdrawalSettingStore,
|
|
commissionRecordStore: commissionRecordStore,
|
|
walletTransactionStore: walletTransactionStore,
|
|
}
|
|
}
|
|
|
|
// GetCommissionSummary 获取我的佣金概览
|
|
func (s *Service) GetCommissionSummary(ctx context.Context) (*dto.MyCommissionSummaryResp, error) {
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
if userType != constants.UserTypeAgent {
|
|
return nil, errors.New(errors.CodeForbidden, "仅代理商用户可访问")
|
|
}
|
|
|
|
shopID := middleware.GetShopIDFromContext(ctx)
|
|
if shopID == 0 {
|
|
return nil, errors.New(errors.CodeForbidden, "无法获取店铺信息")
|
|
}
|
|
|
|
shop, err := s.shopStore.GetByID(ctx, shopID)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
|
|
}
|
|
|
|
// 使用 GetShopCommissionWallet 获取店铺佣金钱包
|
|
wallet, err := s.walletStore.GetShopCommissionWallet(ctx, shopID)
|
|
if err != nil {
|
|
// 钱包不存在时返回空数据
|
|
return &dto.MyCommissionSummaryResp{
|
|
ShopID: shopID,
|
|
ShopName: shop.ShopName,
|
|
}, nil
|
|
}
|
|
|
|
// 计算累计佣金(当前余额 + 冻结余额 + 已提现金额)
|
|
// 由于 Wallet 模型没有 TotalIncome、TotalWithdrawn 字段,
|
|
// 我们需要从 WalletTransaction 表计算或简化处理
|
|
var totalWithdrawn int64
|
|
s.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
|
Where("shop_id = ? AND status IN ?", shopID, []int{2, 4}). // 已通过或已到账
|
|
Select("COALESCE(SUM(actual_amount), 0)").Scan(&totalWithdrawn)
|
|
|
|
totalCommission := wallet.Balance + wallet.FrozenBalance + totalWithdrawn
|
|
|
|
return &dto.MyCommissionSummaryResp{
|
|
ShopID: shopID,
|
|
ShopName: shop.ShopName,
|
|
TotalCommission: totalCommission,
|
|
WithdrawnCommission: totalWithdrawn,
|
|
UnwithdrawCommission: wallet.Balance + wallet.FrozenBalance,
|
|
FrozenCommission: wallet.FrozenBalance,
|
|
WithdrawingCommission: wallet.FrozenBalance, // 提现中的金额等于冻结金额
|
|
AvailableCommission: wallet.Balance,
|
|
}, nil
|
|
}
|
|
|
|
// CreateWithdrawalRequest 发起提现申请
|
|
func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMyWithdrawalReq) (*dto.CreateMyWithdrawalResp, error) {
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
if userType != constants.UserTypeAgent {
|
|
return nil, errors.New(errors.CodeForbidden, "仅代理商用户可访问")
|
|
}
|
|
|
|
shopID := middleware.GetShopIDFromContext(ctx)
|
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
|
if shopID == 0 || currentUserID == 0 {
|
|
return nil, errors.New(errors.CodeForbidden, "无法获取用户信息")
|
|
}
|
|
|
|
// 获取提现配置
|
|
setting, err := s.commissionWithdrawalSettingStore.GetCurrent(ctx)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeInvalidParam, "暂未开放提现功能")
|
|
}
|
|
|
|
// 验证最低提现金额
|
|
if req.Amount < setting.MinWithdrawalAmount {
|
|
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("提现金额不能低于 %.2f 元", float64(setting.MinWithdrawalAmount)/100))
|
|
}
|
|
|
|
// 获取钱包
|
|
wallet, err := s.walletStore.GetShopCommissionWallet(ctx, shopID)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeInsufficientBalance, "钱包不存在")
|
|
}
|
|
|
|
// 验证余额
|
|
if req.Amount > wallet.Balance {
|
|
return nil, errors.New(errors.CodeInsufficientBalance, "可提现余额不足")
|
|
}
|
|
|
|
// 验证今日提现次数
|
|
today := time.Now().Format("2006-01-02")
|
|
todayStart := today + " 00:00:00"
|
|
todayEnd := today + " 23:59:59"
|
|
var todayCount int64
|
|
s.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
|
Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, todayStart, todayEnd).
|
|
Count(&todayCount)
|
|
if int(todayCount) >= setting.DailyWithdrawalLimit {
|
|
return nil, errors.New(errors.CodeInvalidParam, "今日提现次数已达上限")
|
|
}
|
|
|
|
// 计算手续费
|
|
fee := req.Amount * setting.FeeRate / 10000
|
|
actualAmount := req.Amount - fee
|
|
|
|
// 生成提现单号
|
|
withdrawalNo := generateWithdrawalNo()
|
|
|
|
// 构建账户信息 JSON
|
|
accountInfo := map[string]string{
|
|
"account_name": req.AccountName,
|
|
"account_number": req.AccountNumber,
|
|
}
|
|
accountInfoJSON, _ := json.Marshal(accountInfo)
|
|
|
|
var withdrawalRequest *model.CommissionWithdrawalRequest
|
|
|
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
|
// 冻结余额
|
|
if err := tx.WithContext(ctx).Model(&model.Wallet{}).
|
|
Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
|
|
Updates(map[string]interface{}{
|
|
"balance": gorm.Expr("balance - ?", req.Amount),
|
|
"frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("冻结余额失败: %w", err)
|
|
}
|
|
|
|
// 创建提现申请
|
|
withdrawalRequest = &model.CommissionWithdrawalRequest{
|
|
WithdrawalNo: withdrawalNo,
|
|
ShopID: shopID,
|
|
ApplicantID: currentUserID,
|
|
Amount: req.Amount,
|
|
FeeRate: setting.FeeRate,
|
|
Fee: fee,
|
|
ActualAmount: actualAmount,
|
|
WithdrawalMethod: req.WithdrawalMethod,
|
|
AccountInfo: accountInfoJSON,
|
|
Status: 1, // 待审核
|
|
}
|
|
withdrawalRequest.Creator = currentUserID
|
|
withdrawalRequest.Updater = currentUserID
|
|
|
|
if err := tx.WithContext(ctx).Create(withdrawalRequest).Error; err != nil {
|
|
return fmt.Errorf("创建提现申请失败: %w", err)
|
|
}
|
|
|
|
// 创建钱包流水记录
|
|
remark := fmt.Sprintf("提现冻结,单号:%s", withdrawalNo)
|
|
refType := constants.ReferenceTypeWithdrawal
|
|
transaction := &model.WalletTransaction{
|
|
WalletID: wallet.ID,
|
|
UserID: currentUserID,
|
|
TransactionType: constants.TransactionTypeWithdrawal,
|
|
Amount: -req.Amount, // 冻结为负数
|
|
BalanceBefore: wallet.Balance,
|
|
BalanceAfter: wallet.Balance - req.Amount,
|
|
Status: constants.TransactionStatusProcessing, // 处理中
|
|
ReferenceType: &refType,
|
|
ReferenceID: &withdrawalRequest.ID,
|
|
Remark: &remark,
|
|
Creator: currentUserID,
|
|
}
|
|
|
|
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
|
return fmt.Errorf("创建钱包流水失败: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &dto.CreateMyWithdrawalResp{
|
|
ID: withdrawalRequest.ID,
|
|
WithdrawalNo: withdrawalRequest.WithdrawalNo,
|
|
Amount: withdrawalRequest.Amount,
|
|
FeeRate: withdrawalRequest.FeeRate,
|
|
Fee: withdrawalRequest.Fee,
|
|
ActualAmount: withdrawalRequest.ActualAmount,
|
|
Status: withdrawalRequest.Status,
|
|
StatusName: getWithdrawalStatusName(withdrawalRequest.Status),
|
|
CreatedAt: withdrawalRequest.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
}, nil
|
|
}
|
|
|
|
// ListMyWithdrawalRequests 查询我的提现记录
|
|
func (s *Service) ListMyWithdrawalRequests(ctx context.Context, req *dto.MyWithdrawalListReq) (*dto.WithdrawalRequestPageResult, error) {
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
if userType != constants.UserTypeAgent {
|
|
return nil, errors.New(errors.CodeForbidden, "仅代理商用户可访问")
|
|
}
|
|
|
|
shopID := middleware.GetShopIDFromContext(ctx)
|
|
if shopID == 0 {
|
|
return nil, errors.New(errors.CodeForbidden, "无法获取店铺信息")
|
|
}
|
|
|
|
page := req.Page
|
|
pageSize := req.PageSize
|
|
if page == 0 {
|
|
page = 1
|
|
}
|
|
if pageSize == 0 {
|
|
pageSize = constants.DefaultPageSize
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
|
Where("shop_id = ?", shopID)
|
|
|
|
if req.Status != nil {
|
|
query = query.Where("status = ?", *req.Status)
|
|
}
|
|
if req.StartTime != "" {
|
|
query = query.Where("created_at >= ?", req.StartTime)
|
|
}
|
|
if req.EndTime != "" {
|
|
query = query.Where("created_at <= ?", req.EndTime)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, fmt.Errorf("统计提现记录失败: %w", err)
|
|
}
|
|
|
|
var requests []model.CommissionWithdrawalRequest
|
|
offset := (page - 1) * pageSize
|
|
if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&requests).Error; err != nil {
|
|
return nil, fmt.Errorf("查询提现记录失败: %w", err)
|
|
}
|
|
|
|
items := make([]dto.WithdrawalRequestItem, 0, len(requests))
|
|
for _, r := range requests {
|
|
// 解析账户信息
|
|
accountName, accountNumber := parseAccountInfo(r.AccountInfo)
|
|
|
|
items = append(items, dto.WithdrawalRequestItem{
|
|
ID: r.ID,
|
|
WithdrawalNo: r.WithdrawalNo,
|
|
ShopID: r.ShopID,
|
|
Amount: r.Amount,
|
|
FeeRate: r.FeeRate,
|
|
Fee: r.Fee,
|
|
ActualAmount: r.ActualAmount,
|
|
WithdrawalMethod: r.WithdrawalMethod,
|
|
AccountName: accountName,
|
|
AccountNumber: accountNumber,
|
|
Status: r.Status,
|
|
StatusName: getWithdrawalStatusName(r.Status),
|
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
|
|
return &dto.WithdrawalRequestPageResult{
|
|
Items: items,
|
|
Total: total,
|
|
Page: page,
|
|
Size: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// ListMyCommissionRecords 查询我的佣金明细
|
|
func (s *Service) ListMyCommissionRecords(ctx context.Context, req *dto.MyCommissionRecordListReq) (*dto.MyCommissionRecordPageResult, error) {
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
if userType != constants.UserTypeAgent {
|
|
return nil, errors.New(errors.CodeForbidden, "仅代理商用户可访问")
|
|
}
|
|
|
|
shopID := middleware.GetShopIDFromContext(ctx)
|
|
if shopID == 0 {
|
|
return nil, errors.New(errors.CodeForbidden, "无法获取店铺信息")
|
|
}
|
|
|
|
page := req.Page
|
|
pageSize := req.PageSize
|
|
if page == 0 {
|
|
page = 1
|
|
}
|
|
if pageSize == 0 {
|
|
pageSize = constants.DefaultPageSize
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
|
|
Where("shop_id = ?", shopID)
|
|
|
|
if req.CommissionSource != nil {
|
|
query = query.Where("commission_source = ?", *req.CommissionSource)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, fmt.Errorf("统计佣金记录失败: %w", err)
|
|
}
|
|
|
|
var records []model.CommissionRecord
|
|
offset := (page - 1) * pageSize
|
|
if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&records).Error; err != nil {
|
|
return nil, fmt.Errorf("查询佣金记录失败: %w", err)
|
|
}
|
|
|
|
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,
|
|
CommissionSource: r.CommissionSource,
|
|
Amount: r.Amount,
|
|
Status: r.Status,
|
|
StatusName: getCommissionStatusName(r.Status),
|
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
|
|
return &dto.MyCommissionRecordPageResult{
|
|
Items: items,
|
|
Total: total,
|
|
Page: page,
|
|
Size: pageSize,
|
|
}, 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()
|
|
return fmt.Sprintf("W%s%04d", now.Format("20060102150405"), rand.Intn(10000))
|
|
}
|
|
|
|
// getWithdrawalStatusName 获取提现状态名称
|
|
func getWithdrawalStatusName(status int) string {
|
|
switch status {
|
|
case 1:
|
|
return "待审核"
|
|
case 2:
|
|
return "已通过"
|
|
case 3:
|
|
return "已拒绝"
|
|
case 4:
|
|
return "已到账"
|
|
default:
|
|
return "未知"
|
|
}
|
|
}
|
|
|
|
// getCommissionStatusName 获取佣金状态名称
|
|
func getCommissionStatusName(status int) string {
|
|
switch status {
|
|
case 1:
|
|
return "已冻结"
|
|
case 2:
|
|
return "解冻中"
|
|
case 3:
|
|
return "已发放"
|
|
case 4:
|
|
return "已失效"
|
|
default:
|
|
return "未知"
|
|
}
|
|
}
|
|
|
|
// parseAccountInfo 解析账户信息 JSON
|
|
func parseAccountInfo(data []byte) (accountName, accountNumber string) {
|
|
if len(data) == 0 {
|
|
return "", ""
|
|
}
|
|
var info map[string]string
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return "", ""
|
|
}
|
|
return info["account_name"], info["account_number"]
|
|
}
|