v1.0
This commit is contained in:
@@ -1,211 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"ycc-server/app/main/model"
|
||||
"ycc-server/common/xerr"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
"github.com/zeromicro/go-zero/core/stores/sqlx"
|
||||
)
|
||||
|
||||
type AdminPromotionLinkStatsService struct {
|
||||
logx.Logger
|
||||
AdminPromotionLinkModel model.AdminPromotionLinkModel
|
||||
AdminPromotionLinkStatsTotalModel model.AdminPromotionLinkStatsTotalModel
|
||||
AdminPromotionLinkStatsHistoryModel model.AdminPromotionLinkStatsHistoryModel
|
||||
}
|
||||
|
||||
func NewAdminPromotionLinkStatsService(
|
||||
AdminPromotionLinkModel model.AdminPromotionLinkModel,
|
||||
AdminPromotionLinkStatsTotalModel model.AdminPromotionLinkStatsTotalModel,
|
||||
AdminPromotionLinkStatsHistoryModel model.AdminPromotionLinkStatsHistoryModel,
|
||||
) *AdminPromotionLinkStatsService {
|
||||
return &AdminPromotionLinkStatsService{
|
||||
Logger: logx.WithContext(context.Background()),
|
||||
AdminPromotionLinkModel: AdminPromotionLinkModel,
|
||||
AdminPromotionLinkStatsTotalModel: AdminPromotionLinkStatsTotalModel,
|
||||
AdminPromotionLinkStatsHistoryModel: AdminPromotionLinkStatsHistoryModel,
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTotalStats 确保总统计记录存在,如果不存在则创建
|
||||
func (s *AdminPromotionLinkStatsService) ensureTotalStats(ctx context.Context, session sqlx.Session, linkId int64) (*model.AdminPromotionLinkStatsTotal, error) {
|
||||
totalStats, err := s.AdminPromotionLinkStatsTotalModel.FindOneByLinkId(ctx, linkId)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// 如果记录不存在,创建新记录
|
||||
totalStats = &model.AdminPromotionLinkStatsTotal{
|
||||
LinkId: linkId,
|
||||
ClickCount: 0,
|
||||
PayCount: 0,
|
||||
PayAmount: 0,
|
||||
}
|
||||
_, err = s.AdminPromotionLinkStatsTotalModel.Insert(ctx, session, totalStats)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建总统计记录失败: %+v", err)
|
||||
}
|
||||
// 重新获取创建后的记录
|
||||
totalStats, err = s.AdminPromotionLinkStatsTotalModel.FindOneByLinkId(ctx, linkId)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "获取新创建的总统计记录失败: %+v", err)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询总统计失败: %+v", err)
|
||||
}
|
||||
}
|
||||
return totalStats, nil
|
||||
}
|
||||
|
||||
// ensureHistoryStats 确保历史统计记录存在,如果不存在则创建
|
||||
func (s *AdminPromotionLinkStatsService) ensureHistoryStats(ctx context.Context, session sqlx.Session, linkId int64, today time.Time) (*model.AdminPromotionLinkStatsHistory, error) {
|
||||
historyStats, err := s.AdminPromotionLinkStatsHistoryModel.FindOneByLinkIdStatsDate(ctx, linkId, today)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// 如果记录不存在,创建新记录
|
||||
historyStats = &model.AdminPromotionLinkStatsHistory{
|
||||
LinkId: linkId,
|
||||
StatsDate: today,
|
||||
ClickCount: 0,
|
||||
PayCount: 0,
|
||||
PayAmount: 0,
|
||||
}
|
||||
_, err = s.AdminPromotionLinkStatsHistoryModel.Insert(ctx, session, historyStats)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建今日统计记录失败: %+v", err)
|
||||
}
|
||||
// 重新获取创建后的记录
|
||||
historyStats, err = s.AdminPromotionLinkStatsHistoryModel.FindOneByLinkIdStatsDate(ctx, linkId, today)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "获取新创建的今日统计记录失败: %+v", err)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询今日统计记录失败: %+v", err)
|
||||
}
|
||||
}
|
||||
return historyStats, nil
|
||||
}
|
||||
|
||||
// UpdateLinkStats 更新推广链接统计
|
||||
func (s *AdminPromotionLinkStatsService) UpdateLinkStats(ctx context.Context, linkId int64) error {
|
||||
return s.AdminPromotionLinkStatsTotalModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
|
||||
// 确保总统计记录存在
|
||||
totalStats, err := s.ensureTotalStats(ctx, session, linkId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新总统计
|
||||
totalStats.ClickCount++
|
||||
totalStats.LastClickTime = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
err = s.AdminPromotionLinkStatsTotalModel.UpdateWithVersion(ctx, session, totalStats)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新总统计失败: %+v", err)
|
||||
}
|
||||
|
||||
// 确保历史统计记录存在
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
historyStats, err := s.ensureHistoryStats(ctx, session, linkId, today)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新历史统计
|
||||
historyStats.ClickCount++
|
||||
historyStats.LastClickTime = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
err = s.AdminPromotionLinkStatsHistoryModel.UpdateWithVersion(ctx, session, historyStats)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新历史统计失败: %+v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePaymentStats 更新付费统计
|
||||
func (s *AdminPromotionLinkStatsService) UpdatePaymentStats(ctx context.Context, linkId int64, amount float64) error {
|
||||
return s.AdminPromotionLinkStatsTotalModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
|
||||
// 确保总统计记录存在
|
||||
totalStats, err := s.ensureTotalStats(ctx, session, linkId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新总统计
|
||||
totalStats.PayCount++
|
||||
totalStats.PayAmount += amount
|
||||
totalStats.LastPayTime = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
err = s.AdminPromotionLinkStatsTotalModel.UpdateWithVersion(ctx, session, totalStats)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新总统计失败: %+v", err)
|
||||
}
|
||||
|
||||
// 确保历史统计记录存在
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
historyStats, err := s.ensureHistoryStats(ctx, session, linkId, today)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新历史统计
|
||||
historyStats.PayCount++
|
||||
historyStats.PayAmount += amount
|
||||
historyStats.LastPayTime = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
err = s.AdminPromotionLinkStatsHistoryModel.UpdateWithVersion(ctx, session, historyStats)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新历史统计失败: %+v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateLinkStats 创建新的推广链接统计记录
|
||||
func (s *AdminPromotionLinkStatsService) CreateLinkStats(ctx context.Context, linkId int64) error {
|
||||
return s.AdminPromotionLinkStatsTotalModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
|
||||
// 检查总统计记录是否已存在
|
||||
_, err := s.AdminPromotionLinkStatsTotalModel.FindOneByLinkId(ctx, linkId)
|
||||
if err == nil {
|
||||
// 记录已存在,不需要创建
|
||||
return nil
|
||||
}
|
||||
if err != model.ErrNotFound {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询总统计记录失败: %+v", err)
|
||||
}
|
||||
|
||||
// 创建总统计记录
|
||||
totalStats := &model.AdminPromotionLinkStatsTotal{
|
||||
LinkId: linkId,
|
||||
ClickCount: 0,
|
||||
PayCount: 0,
|
||||
PayAmount: 0,
|
||||
}
|
||||
_, err = s.AdminPromotionLinkStatsTotalModel.Insert(ctx, session, totalStats)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建总统计记录失败: %+v", err)
|
||||
}
|
||||
|
||||
// 创建今日历史统计记录
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
historyStats := &model.AdminPromotionLinkStatsHistory{
|
||||
LinkId: linkId,
|
||||
StatsDate: today,
|
||||
ClickCount: 0,
|
||||
PayCount: 0,
|
||||
PayAmount: 0,
|
||||
}
|
||||
_, err = s.AdminPromotionLinkStatsHistoryModel.Insert(ctx, session, historyStats)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建历史统计记录失败: %+v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
"ycc-server/app/main/api/internal/config"
|
||||
@@ -17,21 +18,22 @@ import (
|
||||
|
||||
// AgentService 新代理系统服务
|
||||
type AgentService struct {
|
||||
config config.Config
|
||||
OrderModel model.OrderModel
|
||||
AgentModel model.AgentModel
|
||||
AgentWalletModel model.AgentWalletModel
|
||||
AgentRelationModel model.AgentRelationModel
|
||||
AgentLinkModel model.AgentLinkModel
|
||||
AgentOrderModel model.AgentOrderModel
|
||||
AgentCommissionModel model.AgentCommissionModel
|
||||
AgentRebateModel model.AgentRebateModel
|
||||
AgentUpgradeModel model.AgentUpgradeModel
|
||||
AgentWithdrawalModel model.AgentWithdrawalModel
|
||||
AgentConfigModel model.AgentConfigModel
|
||||
config config.Config
|
||||
OrderModel model.OrderModel
|
||||
AgentModel model.AgentModel
|
||||
AgentWalletModel model.AgentWalletModel
|
||||
AgentRelationModel model.AgentRelationModel
|
||||
AgentLinkModel model.AgentLinkModel
|
||||
AgentOrderModel model.AgentOrderModel
|
||||
AgentCommissionModel model.AgentCommissionModel
|
||||
AgentRebateModel model.AgentRebateModel
|
||||
AgentUpgradeModel model.AgentUpgradeModel
|
||||
AgentWithdrawalModel model.AgentWithdrawalModel
|
||||
AgentConfigModel model.AgentConfigModel
|
||||
AgentProductConfigModel model.AgentProductConfigModel
|
||||
AgentRealNameModel model.AgentRealNameModel
|
||||
AgentRealNameModel model.AgentRealNameModel
|
||||
AgentWithdrawalTaxModel model.AgentWithdrawalTaxModel
|
||||
AgentFreezeTaskModel model.AgentFreezeTaskModel // 冻结任务模型(需要先运行SQL并生成model)
|
||||
}
|
||||
|
||||
// NewAgentService 创建新的代理服务
|
||||
@@ -51,23 +53,25 @@ func NewAgentService(
|
||||
agentProductConfigModel model.AgentProductConfigModel,
|
||||
agentRealNameModel model.AgentRealNameModel,
|
||||
agentWithdrawalTaxModel model.AgentWithdrawalTaxModel,
|
||||
agentFreezeTaskModel model.AgentFreezeTaskModel, // 冻结任务模型(需要先运行SQL并生成model)
|
||||
) *AgentService {
|
||||
return &AgentService{
|
||||
config: c,
|
||||
OrderModel: orderModel,
|
||||
AgentModel: agentModel,
|
||||
AgentWalletModel: agentWalletModel,
|
||||
AgentRelationModel: agentRelationModel,
|
||||
AgentLinkModel: agentLinkModel,
|
||||
AgentOrderModel: agentOrderModel,
|
||||
AgentCommissionModel: agentCommissionModel,
|
||||
AgentRebateModel: agentRebateModel,
|
||||
AgentUpgradeModel: agentUpgradeModel,
|
||||
AgentWithdrawalModel: agentWithdrawalModel,
|
||||
AgentConfigModel: agentConfigModel,
|
||||
config: c,
|
||||
OrderModel: orderModel,
|
||||
AgentModel: agentModel,
|
||||
AgentWalletModel: agentWalletModel,
|
||||
AgentRelationModel: agentRelationModel,
|
||||
AgentLinkModel: agentLinkModel,
|
||||
AgentOrderModel: agentOrderModel,
|
||||
AgentCommissionModel: agentCommissionModel,
|
||||
AgentRebateModel: agentRebateModel,
|
||||
AgentUpgradeModel: agentUpgradeModel,
|
||||
AgentWithdrawalModel: agentWithdrawalModel,
|
||||
AgentConfigModel: agentConfigModel,
|
||||
AgentProductConfigModel: agentProductConfigModel,
|
||||
AgentRealNameModel: agentRealNameModel,
|
||||
AgentRealNameModel: agentRealNameModel,
|
||||
AgentWithdrawalTaxModel: agentWithdrawalTaxModel,
|
||||
AgentFreezeTaskModel: agentFreezeTaskModel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,27 +100,42 @@ func (s *AgentService) AgentProcess(ctx context.Context, order *model.Order) err
|
||||
return errors.Wrapf(err, "查询代理信息失败, agentId: %d", agentOrder.AgentId)
|
||||
}
|
||||
|
||||
// 4. 获取系统配置
|
||||
basePrice, err := s.getConfigFloat(ctx, "base_price")
|
||||
// 4. 获取产品配置(必须存在)
|
||||
productConfig, err := s.AgentProductConfigModel.FindOneByProductId(ctx, order.ProductId)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "获取基础底价配置失败")
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return errors.Wrapf(err, "产品配置不存在, productId: %d,请先在后台配置产品价格参数", order.ProductId)
|
||||
}
|
||||
return errors.Wrapf(err, "查询产品配置失败, productId: %d", order.ProductId)
|
||||
}
|
||||
|
||||
// 6. 使用事务处理订单
|
||||
return s.AgentWalletModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error {
|
||||
// 6.1 计算实际底价和代理收益
|
||||
levelBonus := s.getLevelBonus(agent.Level)
|
||||
// 5. 使用事务处理订单
|
||||
err = s.AgentWalletModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error {
|
||||
// 5.1 获取等级加成
|
||||
levelBonus, err := s.getLevelBonus(transCtx, agent.Level)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "获取等级加成配置失败")
|
||||
}
|
||||
|
||||
// 5.2 使用产品配置的底价计算实际底价
|
||||
basePrice := productConfig.BasePrice
|
||||
actualBasePrice := basePrice + float64(levelBonus)
|
||||
|
||||
// 6.2 计算提价成本
|
||||
priceThreshold, _ := s.getConfigFloat(ctx, "price_threshold")
|
||||
priceFeeRate, _ := s.getConfigFloat(ctx, "price_fee_rate")
|
||||
// 5.3 计算提价成本(使用产品配置)
|
||||
priceThreshold := 0.0
|
||||
priceFeeRate := 0.0
|
||||
if productConfig.PriceThreshold.Valid {
|
||||
priceThreshold = productConfig.PriceThreshold.Float64
|
||||
}
|
||||
if productConfig.PriceFeeRate.Valid {
|
||||
priceFeeRate = productConfig.PriceFeeRate.Float64
|
||||
}
|
||||
priceCost := s.calculatePriceCost(agentOrder.SetPrice, priceThreshold, priceFeeRate)
|
||||
|
||||
// 6.3 计算代理收益
|
||||
// 5.4 计算代理收益
|
||||
agentProfit := agentOrder.SetPrice - actualBasePrice - priceCost
|
||||
|
||||
// 6.4 更新代理订单记录
|
||||
// 5.5 更新代理订单记录
|
||||
agentOrder.ProcessStatus = 1
|
||||
agentOrder.ProcessTime = lzUtils.TimeToNullTime(time.Now())
|
||||
agentOrder.ProcessRemark = lzUtils.StringToNullString("处理成功")
|
||||
@@ -124,12 +143,14 @@ func (s *AgentService) AgentProcess(ctx context.Context, order *model.Order) err
|
||||
return errors.Wrapf(err, "更新代理订单失败")
|
||||
}
|
||||
|
||||
// 6.5 发放代理佣金
|
||||
if err := s.giveAgentCommission(transCtx, session, agentOrder.AgentId, order.Id, order.ProductId, agentProfit); err != nil {
|
||||
return errors.Wrapf(err, "发放代理佣金失败")
|
||||
// 5.6 发放代理佣金(传入订单单价用于冻结判断)
|
||||
// 注意:冻结任务会在 agentProcess.go 中通过查询订单的冻结任务来发送解冻任务
|
||||
_, commissionErr := s.giveAgentCommission(transCtx, session, agentOrder.AgentId, order.Id, order.ProductId, agentProfit, agentOrder.SetPrice)
|
||||
if commissionErr != nil {
|
||||
return errors.Wrapf(commissionErr, "发放代理佣金失败")
|
||||
}
|
||||
|
||||
// 6.6 分配等级加成返佣给上级链
|
||||
// 5.7 分配等级加成返佣给上级链
|
||||
if levelBonus > 0 {
|
||||
if err := s.distributeLevelBonus(transCtx, session, agent, order.Id, order.ProductId, float64(levelBonus), levelBonus); err != nil {
|
||||
return errors.Wrapf(err, "分配等级加成返佣失败")
|
||||
@@ -138,20 +159,39 @@ func (s *AgentService) AgentProcess(ctx context.Context, order *model.Order) err
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// getLevelBonus 获取等级加成
|
||||
func (s *AgentService) getLevelBonus(level int64) int64 {
|
||||
// getLevelBonus 获取等级加成(从配置表读取)
|
||||
func (s *AgentService) getLevelBonus(ctx context.Context, level int64) (int64, error) {
|
||||
var configKey string
|
||||
switch level {
|
||||
case 1: // 普通
|
||||
return 6
|
||||
configKey = "level_1_bonus"
|
||||
case 2: // 黄金
|
||||
return 3
|
||||
configKey = "level_2_bonus"
|
||||
case 3: // 钻石
|
||||
return 0
|
||||
configKey = "level_3_bonus"
|
||||
default:
|
||||
return 0
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
bonus, err := s.getConfigFloat(ctx, configKey)
|
||||
if err != nil {
|
||||
// 配置不存在时返回默认值
|
||||
logx.Errorf("获取等级加成配置失败, level: %d, key: %s, err: %v,使用默认值", level, configKey, err)
|
||||
switch level {
|
||||
case 1:
|
||||
return 6, nil
|
||||
case 2:
|
||||
return 3, nil
|
||||
case 3:
|
||||
return 0, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
return int64(bonus), nil
|
||||
}
|
||||
|
||||
// calculatePriceCost 计算提价成本
|
||||
@@ -163,7 +203,9 @@ func (s *AgentService) calculatePriceCost(setPrice, priceThreshold, priceFeeRate
|
||||
}
|
||||
|
||||
// giveAgentCommission 发放代理佣金
|
||||
func (s *AgentService) giveAgentCommission(ctx context.Context, session sqlx.Session, agentId, orderId, productId int64, amount float64) error {
|
||||
// orderPrice: 订单单价,用于判断是否需要冻结
|
||||
// 返回:freezeTaskId(如果有冻结任务),error
|
||||
func (s *AgentService) giveAgentCommission(ctx context.Context, session sqlx.Session, agentId, orderId, productId int64, amount float64, orderPrice float64) (int64, error) {
|
||||
// 1. 创建佣金记录
|
||||
commission := &model.AgentCommission{
|
||||
AgentId: agentId,
|
||||
@@ -172,23 +214,94 @@ func (s *AgentService) giveAgentCommission(ctx context.Context, session sqlx.Ses
|
||||
Amount: amount,
|
||||
Status: 1, // 已发放
|
||||
}
|
||||
if _, err := s.AgentCommissionModel.Insert(ctx, session, commission); err != nil {
|
||||
return errors.Wrapf(err, "创建佣金记录失败")
|
||||
commissionResult, err := s.AgentCommissionModel.Insert(ctx, session, commission)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "创建佣金记录失败")
|
||||
}
|
||||
commissionId, _ := commissionResult.LastInsertId()
|
||||
|
||||
// 2. 判断是否需要冻结
|
||||
// 2.1 获取冻结阈值配置(默认100元)
|
||||
freezeThreshold, err := s.getConfigFloat(ctx, "commission_freeze_threshold")
|
||||
if err != nil {
|
||||
// 配置不存在时使用默认值100元
|
||||
freezeThreshold = 100.0
|
||||
logx.Errorf("获取冻结阈值配置失败,使用默认值100元, orderId: %d, err: %v", orderId, err)
|
||||
}
|
||||
|
||||
// 2. 更新钱包余额
|
||||
// 2.2 判断订单单价是否达到冻结阈值
|
||||
freezeAmount := 0.0
|
||||
var freezeTaskId int64 = 0
|
||||
if orderPrice >= freezeThreshold {
|
||||
// 2.3 获取冻结比例配置(默认10%)
|
||||
freezeRatio, err := s.getConfigFloat(ctx, "commission_freeze_ratio")
|
||||
if err != nil {
|
||||
// 配置不存在时使用默认值0.1(10%)
|
||||
freezeRatio = 0.1
|
||||
logx.Errorf("获取冻结比例配置失败,使用默认值10%%, orderId: %d, err: %v", orderId, err)
|
||||
}
|
||||
|
||||
// 计算冻结金额:订单单价的10%
|
||||
freezeAmountByPrice := orderPrice * freezeRatio
|
||||
|
||||
// 冻结金额不能超过佣金金额
|
||||
if freezeAmountByPrice > amount {
|
||||
freezeAmount = amount
|
||||
} else {
|
||||
freezeAmount = freezeAmountByPrice
|
||||
}
|
||||
|
||||
// 如果冻结金额大于0,创建冻结任务
|
||||
if freezeAmount > 0 {
|
||||
// 2.4 获取解冻天数配置(默认30天,即1个月)
|
||||
unfreezeDays, err := s.getConfigInt(ctx, "commission_freeze_days")
|
||||
if err != nil {
|
||||
// 配置不存在时使用默认值30天
|
||||
unfreezeDays = 30
|
||||
logx.Errorf("获取解冻天数配置失败,使用默认值30天, orderId: %d, err: %v", orderId, err)
|
||||
}
|
||||
// 计算解冻时间(从配置读取的天数后)
|
||||
// 注意:配置只在创建任务时读取,已创建的任务不受后续配置修改影响
|
||||
unfreezeTime := time.Now().AddDate(0, 0, int(unfreezeDays))
|
||||
|
||||
// 创建冻结任务记录
|
||||
freezeTask := &model.AgentFreezeTask{
|
||||
AgentId: agentId,
|
||||
OrderId: orderId,
|
||||
CommissionId: commissionId,
|
||||
FreezeAmount: freezeAmount,
|
||||
OrderPrice: orderPrice,
|
||||
FreezeRatio: freezeRatio,
|
||||
Status: 1, // 待解冻
|
||||
FreezeTime: time.Now(),
|
||||
UnfreezeTime: unfreezeTime,
|
||||
Remark: lzUtils.StringToNullString(fmt.Sprintf("订单单价%.2f元,冻结比例%.2f%%,解冻天数%d天", orderPrice, freezeRatio*100, unfreezeDays)),
|
||||
}
|
||||
freezeTaskResult, err := s.AgentFreezeTaskModel.Insert(ctx, session, freezeTask)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "创建冻结任务失败")
|
||||
}
|
||||
freezeTaskId, _ = freezeTaskResult.LastInsertId()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 更新钱包余额
|
||||
wallet, err := s.AgentWalletModel.FindOneByAgentId(ctx, agentId)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "查询钱包失败, agentId: %d", agentId)
|
||||
return 0, errors.Wrapf(err, "查询钱包失败, agentId: %d", agentId)
|
||||
}
|
||||
|
||||
wallet.Balance += amount
|
||||
wallet.TotalEarnings += amount
|
||||
// 实际到账金额 = 佣金金额 - 冻结金额
|
||||
actualAmount := amount - freezeAmount
|
||||
|
||||
wallet.Balance += actualAmount
|
||||
wallet.FrozenBalance += freezeAmount
|
||||
wallet.TotalEarnings += amount // 累计收益包含冻结部分
|
||||
if err := s.AgentWalletModel.UpdateWithVersion(ctx, session, wallet); err != nil {
|
||||
return errors.Wrapf(err, "更新钱包失败")
|
||||
return 0, errors.Wrapf(err, "更新钱包失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return freezeTaskId, nil
|
||||
}
|
||||
|
||||
// distributeLevelBonus 分配等级加成返佣给上级链
|
||||
@@ -219,7 +332,51 @@ func (s *AgentService) distributeLevelBonus(ctx context.Context, session sqlx.Se
|
||||
return nil
|
||||
}
|
||||
|
||||
// distributeNormalAgentBonus 普通代理的等级加成返佣分配(6元)
|
||||
// distributeNormalAgentBonus 普通代理的等级加成返佣分配
|
||||
//
|
||||
// 功能说明:根据普通代理的直接上级等级,按照规则分配等级加成返佣
|
||||
//
|
||||
// 参数说明:
|
||||
// - amount: 等级加成总额(例如:6元)
|
||||
// - levelBonusInt: 等级加成整数(用于记录)
|
||||
//
|
||||
// 分配规则总览:
|
||||
// 1. 直接上级是钻石:等级加成全部给钻石
|
||||
// 2. 直接上级是黄金:一部分给黄金(配置:direct_parent_amount_gold,默认3元),剩余给钻石上级
|
||||
// 3. 直接上级是普通:一部分给直接上级(配置:direct_parent_amount_normal,默认2元),剩余给钻石/黄金上级
|
||||
//
|
||||
// 覆盖的所有情况:
|
||||
//
|
||||
// 情况1:普通(推广人) -> 钻石(直接上级)
|
||||
// => 全部给钻石
|
||||
//
|
||||
// 情况2:普通(推广人) -> 黄金(直接上级) -> 钻石
|
||||
// => 一部分给黄金,剩余给钻石
|
||||
//
|
||||
// 情况3:普通(推广人) -> 黄金(直接上级) -> 无钻石上级
|
||||
// => 一部分给黄金,剩余归平台
|
||||
//
|
||||
// 情况4:普通(推广人) -> 普通(直接上级) -> 钻石
|
||||
// => 一部分给直接上级普通,剩余全部给钻石
|
||||
//
|
||||
// 情况5:普通(推广人) -> 普通(直接上级) -> 黄金 -> 钻石
|
||||
// => 一部分给直接上级普通(例如2元),一部分给黄金(等级加成差减去给普通的,例如3-2=1元),剩余给钻石(例如3元)
|
||||
//
|
||||
// 情况6:普通(推广人) -> 普通(直接上级) -> 黄金(无钻石)
|
||||
// => 一部分给直接上级普通,剩余一部分给黄金(最多3元),超出归平台
|
||||
//
|
||||
// 情况7:普通(推广人) -> 普通(直接上级) -> 普通 -> 钻石
|
||||
// => 一部分给直接上级普通,剩余全部给钻石(跳过中间普通代理)
|
||||
//
|
||||
// 情况8:普通(推广人) -> 普通(直接上级) -> 普通 -> 黄金(无钻石)
|
||||
// => 一部分给直接上级普通,剩余一部分给黄金(最多3元),超出归平台(跳过中间普通代理)
|
||||
//
|
||||
// 情况9:普通(推广人) -> 普通(直接上级) -> 普通 -> 普通...(全部是普通)
|
||||
// => 一部分给直接上级普通,剩余归平台
|
||||
//
|
||||
// 注意:findDiamondParent 和 findGoldParent 会自动跳过中间的所有普通代理,
|
||||
//
|
||||
// 直接向上查找到第一个钻石或黄金代理
|
||||
func (s *AgentService) distributeNormalAgentBonus(ctx context.Context, session sqlx.Session, agent *model.Agent, orderId, productId int64, amount float64, levelBonusInt int64) error {
|
||||
// 1. 查找直接上级
|
||||
parent, err := s.findDirectParent(ctx, agent.Id)
|
||||
@@ -232,67 +389,240 @@ func (s *AgentService) distributeNormalAgentBonus(ctx context.Context, session s
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. 给直接上级分配固定金额
|
||||
var directParentAmount float64
|
||||
// 2. 根据直接上级等级分配
|
||||
switch parent.Level {
|
||||
case 3: // 钻石
|
||||
directParentAmount = 6
|
||||
case 2: // 黄金
|
||||
directParentAmount = 3
|
||||
case 1: // 普通
|
||||
directParentAmount = 2
|
||||
default:
|
||||
directParentAmount = 0
|
||||
}
|
||||
case 3: // 直接上级是钻石代理的情况
|
||||
// ========== 直接上级是钻石:等级加成全部给钻石上级 ==========
|
||||
// 场景示例:
|
||||
// - 普通(推广人) -> 钻石(直接上级):等级加成6元全部给钻石
|
||||
// 说明:如果直接上级就是钻石,不需要再向上查找,全部返佣给直接上级钻石
|
||||
// rebateType = 2:表示钻石上级返佣
|
||||
return s.giveRebate(ctx, session, parent.Id, agent.Id, orderId, productId, amount, levelBonusInt, 2)
|
||||
|
||||
if directParentAmount > 0 {
|
||||
if err := s.giveRebate(ctx, session, parent.Id, agent.Id, orderId, productId, directParentAmount, levelBonusInt, 1); err != nil {
|
||||
return errors.Wrapf(err, "给直接上级返佣失败")
|
||||
case 2: // 直接上级是黄金代理的情况
|
||||
// ========== 步骤1:给直接上级黄金代理返佣 ==========
|
||||
// 配置键:direct_parent_amount_gold(普通代理给直接上级黄金代理的返佣金额)
|
||||
// 默认值:3.0元
|
||||
// 说明:这部分金额给直接上级黄金代理,剩余部分继续向上分配给钻石上级
|
||||
goldRebateAmount, err := s.getRebateConfigFloat(ctx, "direct_parent_amount_gold", 3.0)
|
||||
if err != nil {
|
||||
logx.Errorf("获取黄金返佣配置失败,使用默认值3元: %v", err)
|
||||
goldRebateAmount = 3.0 // 配置读取失败时使用默认值3元
|
||||
}
|
||||
}
|
||||
|
||||
remaining := amount - directParentAmount
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 分配剩余金额
|
||||
// 确定查找起点:直接上级是普通时从直接上级开始查找,否则从直接上级的上级开始查找
|
||||
searchStart := parent
|
||||
if parent.Level != 1 {
|
||||
searchStartParent, err := s.findDirectParent(ctx, parent.Id)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return errors.Wrapf(err, "查找上级的上级失败")
|
||||
// 计算给直接上级黄金代理的返佣金额(不能超过总等级加成金额)
|
||||
goldAmount := goldRebateAmount // 默认3元
|
||||
if goldAmount > amount {
|
||||
// 如果配置金额大于等级加成总额,则只给总额(防止配置错误导致负数)
|
||||
goldAmount = amount
|
||||
}
|
||||
if searchStartParent != nil {
|
||||
searchStart = searchStartParent
|
||||
}
|
||||
}
|
||||
|
||||
if searchStart != nil {
|
||||
// 查找上级链中的钻石和黄金
|
||||
diamondParent, _ := s.findDiamondParent(ctx, searchStart.Id)
|
||||
goldParent, _ := s.findGoldParent(ctx, searchStart.Id)
|
||||
|
||||
// 按优先级分配剩余金额
|
||||
if diamondParent != nil {
|
||||
// 优先级1:有钻石,剩余金额全部给钻石
|
||||
return s.giveRebate(ctx, session, diamondParent.Id, agent.Id, orderId, productId, remaining, levelBonusInt, 2)
|
||||
} else if goldParent != nil {
|
||||
// 优先级2:只有黄金,最多3元给黄金,剩余归平台
|
||||
goldAmount := remaining
|
||||
if goldAmount > 3 {
|
||||
goldAmount = 3
|
||||
}
|
||||
if err := s.giveRebate(ctx, session, goldParent.Id, agent.Id, orderId, productId, goldAmount, levelBonusInt, 3); err != nil {
|
||||
// 发放返佣给直接上级黄金代理
|
||||
// rebateType = 1:表示直接上级返佣
|
||||
if goldAmount > 0 {
|
||||
if err := s.giveRebate(ctx, session, parent.Id, agent.Id, orderId, productId, goldAmount, levelBonusInt, 1); err != nil {
|
||||
return errors.Wrapf(err, "给黄金上级返佣失败")
|
||||
}
|
||||
// 剩余归平台(不需要记录)
|
||||
}
|
||||
// 优先级3:都没有,剩余金额归平台(不需要记录)
|
||||
}
|
||||
|
||||
return nil
|
||||
// ========== 步骤2:计算剩余金额并分配给钻石上级 ==========
|
||||
// 剩余金额 = 总等级加成 - 已给黄金上级的金额
|
||||
// 例如:等级加成6元 - 给黄金上级3元 = 剩余3元
|
||||
remaining := amount - goldAmount
|
||||
if remaining > 0 {
|
||||
// 从黄金上级开始向上查找钻石上级
|
||||
// 场景示例:
|
||||
// - 普通(推广人) -> 黄金(直接上级) -> 钻石:剩余3元给钻石
|
||||
// - 普通(推广人) -> 黄金(直接上级) -> 普通 -> 钻石:剩余3元给钻石(跳过中间普通代理)
|
||||
// - 普通(推广人) -> 黄金(直接上级) -> 无上级:剩余3元归平台(没有钻石上级)
|
||||
diamondParent, err := s.findDiamondParent(ctx, parent.Id)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return errors.Wrapf(err, "查找钻石上级失败")
|
||||
}
|
||||
if diamondParent != nil {
|
||||
// 找到钻石上级,剩余金额全部给钻石上级
|
||||
// rebateType = 2:表示钻石上级返佣
|
||||
return s.giveRebate(ctx, session, diamondParent.Id, agent.Id, orderId, productId, remaining, levelBonusInt, 2)
|
||||
}
|
||||
// 找不到钻石上级,剩余金额归平台(不需要记录)
|
||||
// 例如:等级加成6元,给黄金3元,剩余3元但找不到钻石上级,则剩余3元归平台
|
||||
}
|
||||
return nil
|
||||
|
||||
case 1: // 直接上级是普通代理的情况
|
||||
// ========== 步骤1:给直接上级普通代理返佣 ==========
|
||||
// 配置键:direct_parent_amount_normal(普通代理给直接上级普通代理的返佣金额)
|
||||
// 默认值:2.0元
|
||||
// 说明:无论后续层级有多少普通代理,这部分金额只给推广人的直接上级
|
||||
normalRebateAmount, err := s.getRebateConfigFloat(ctx, "direct_parent_amount_normal", 2.0)
|
||||
if err != nil {
|
||||
logx.Errorf("获取普通返佣配置失败,使用默认值2元: %v", err)
|
||||
normalRebateAmount = 2.0 // 配置读取失败时使用默认值2元
|
||||
}
|
||||
|
||||
// 计算给直接上级的返佣金额(不能超过总等级加成金额)
|
||||
directAmount := normalRebateAmount // 默认2元
|
||||
if directAmount > amount {
|
||||
// 如果配置金额大于等级加成总额,则只给总额(防止配置错误导致负数)
|
||||
directAmount = amount
|
||||
}
|
||||
|
||||
// 发放返佣给直接上级普通代理
|
||||
// rebateType = 1:表示直接上级返佣
|
||||
if directAmount > 0 {
|
||||
if err := s.giveRebate(ctx, session, parent.Id, agent.Id, orderId, productId, directAmount, levelBonusInt, 1); err != nil {
|
||||
return errors.Wrapf(err, "给直接上级返佣失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 步骤2:计算剩余金额 ==========
|
||||
// 剩余金额 = 总等级加成 - 已给直接上级的金额
|
||||
// 例如:等级加成6元 - 给直接上级2元 = 剩余4元
|
||||
remaining := amount - directAmount
|
||||
if remaining <= 0 {
|
||||
// 如果没有剩余,直接返回(所有金额已分配给直接上级)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 步骤3:从直接上级开始向上查找钻石和黄金代理 ==========
|
||||
// 注意:findDiamondParent 和 findGoldParent 会自动跳过中间的所有普通代理
|
||||
// 例如:
|
||||
// - 普通 -> 普通 -> 普通 -> 钻石:会跳过中间的普通代理,直接找到钻石
|
||||
// - 普通 -> 普通 -> 黄金 -> 钻石:会找到钻石(优先级更高)
|
||||
// - 普通 -> 黄金:会找到黄金
|
||||
diamondParent, _ := s.findDiamondParent(ctx, parent.Id) // 向上查找钻石上级(跳过所有普通和黄金)
|
||||
goldParent, _ := s.findGoldParent(ctx, parent.Id) // 向上查找黄金上级(跳过所有普通)
|
||||
|
||||
// ========== 步骤4:按优先级分配剩余金额 ==========
|
||||
// 优先级规则:
|
||||
// 1. 优先给钻石上级(如果存在)
|
||||
// 2. 其次给黄金上级(如果钻石不存在)
|
||||
// 3. 最后归平台(如果钻石和黄金都不存在)
|
||||
|
||||
if diamondParent != nil {
|
||||
// ========== 情况A:找到钻石上级 ==========
|
||||
// 场景示例:
|
||||
// - 普通(推广人) -> 普通(直接上级) -> 钻石:给普通2元,剩余4元给钻石
|
||||
// - 普通(推广人) -> 普通(直接上级) -> 黄金 -> 钻石:给普通2元,给黄金1元,剩余3元给钻石
|
||||
// - 普通(推广人) -> 普通(直接上级) -> 普通 -> 普通 -> 钻石:给普通2元,剩余4元给钻石(跳过中间普通代理)
|
||||
//
|
||||
// 分配规则:当有钻石上级时,需要给黄金上级一部分返佣
|
||||
// 1. 等级加成的差 = 普通等级加成 - 黄金等级加成(例如:6元 - 3元 = 3元)
|
||||
// 2. 从这个差中,先给直接上级普通代理(已给,例如2元)
|
||||
// 3. 差减去给普通的部分,剩余给黄金代理(例如:3元 - 2元 = 1元)
|
||||
// 4. 最后剩余部分给钻石(例如:6元 - 2元 - 1元 = 3元)
|
||||
|
||||
// 步骤A1:计算等级加成的差
|
||||
// 获取普通代理等级加成(当前代理的等级加成,即amount)
|
||||
normalBonus := amount // 例如:6元
|
||||
// 获取黄金代理等级加成
|
||||
goldBonus, err := s.getLevelBonus(ctx, 2) // 黄金等级加成
|
||||
if err != nil {
|
||||
logx.Errorf("获取黄金等级加成配置失败,使用默认值3元: %v", err)
|
||||
goldBonus = 3 // 默认3元
|
||||
}
|
||||
// 计算等级加成的差
|
||||
bonusDiff := normalBonus - float64(goldBonus) // 例如:6元 - 3元 = 3元
|
||||
|
||||
// 步骤A2:如果有黄金上级,给黄金上级一部分返佣
|
||||
// 规则说明:
|
||||
// - 等级加成的差(bonusDiff)代表普通代理和黄金代理的等级加成差异
|
||||
// - 从这个差中,先分配给直接上级普通代理(directAmount,已分配)
|
||||
// - 剩余的差分配给黄金代理:goldRebateAmount = bonusDiff - directAmount
|
||||
// - 如果差小于等于已给普通的部分,则不给黄金(这种情况理论上不应该发生,因为差应该>=给普通的部分)
|
||||
if goldParent != nil && bonusDiff > 0 {
|
||||
// 计算给黄金上级的金额 = 等级加成差 - 已给普通上级的金额
|
||||
// 例如:等级加成差3元 - 已给普通2元 = 给黄金1元
|
||||
goldRebateAmount := bonusDiff - directAmount
|
||||
|
||||
// 如果计算出的金额小于等于0,说明差已经被普通代理全部占用了,不给黄金
|
||||
// 例如:如果差是2元,已给普通2元,则 goldRebateAmount = 0,不给黄金
|
||||
if goldRebateAmount > 0 {
|
||||
// 边界检查:确保不超过剩余金额(理论上应该不会超过,但保险起见)
|
||||
if goldRebateAmount > remaining {
|
||||
goldRebateAmount = remaining
|
||||
}
|
||||
|
||||
// 发放返佣给黄金上级
|
||||
// rebateType = 3:表示黄金上级返佣
|
||||
if err := s.giveRebate(ctx, session, goldParent.Id, agent.Id, orderId, productId, goldRebateAmount, levelBonusInt, 3); err != nil {
|
||||
return errors.Wrapf(err, "给黄金上级返佣失败")
|
||||
}
|
||||
|
||||
// 更新剩余金额(用于后续分配给钻石)
|
||||
// 例如:剩余4元 - 给黄金1元 = 剩余3元(给钻石)
|
||||
remaining = remaining - goldRebateAmount
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤A3:剩余金额全部给钻石上级
|
||||
// rebateType = 2:表示钻石上级返佣
|
||||
if remaining > 0 {
|
||||
return s.giveRebate(ctx, session, diamondParent.Id, agent.Id, orderId, productId, remaining, levelBonusInt, 2)
|
||||
}
|
||||
return nil
|
||||
} else if goldParent != nil {
|
||||
// ========== 情况B:没有钻石上级,但有黄金上级 ==========
|
||||
// 场景示例:
|
||||
// - 普通(推广人) -> 普通(直接上级) -> 黄金(没有钻石):给黄金一部分,剩余归平台
|
||||
// - 普通(推广人) -> 普通(直接上级) -> 普通 -> 黄金(没有钻石):给黄金一部分,剩余归平台(跳过中间普通代理)
|
||||
|
||||
// 配置键:max_gold_rebate_amount(普通代理给黄金上级的最大返佣金额)
|
||||
// 默认值:3.0元
|
||||
// 说明:即使剩余金额超过这个值,也只能给黄金上级最多3元,超出部分归平台
|
||||
maxGoldRebate, err := s.getRebateConfigFloat(ctx, "max_gold_rebate_amount", 3.0)
|
||||
if err != nil {
|
||||
logx.Errorf("获取黄金最大返佣配置失败,使用默认值3元: %v", err)
|
||||
maxGoldRebate = 3.0 // 配置读取失败时使用默认值3元
|
||||
}
|
||||
|
||||
// 计算给黄金上级的返佣金额
|
||||
goldAmount := remaining // 剩余金额
|
||||
if goldAmount > maxGoldRebate {
|
||||
// 如果剩余金额超过最大限额,则只给最大限额(例如:剩余4元,但最多只能给3元)
|
||||
goldAmount = maxGoldRebate
|
||||
}
|
||||
// 例如:剩余4元,最大限额3元,则给黄金3元,剩余1元归平台
|
||||
|
||||
// 发放返佣给黄金上级
|
||||
// rebateType = 3:表示黄金上级返佣
|
||||
if goldAmount > 0 {
|
||||
if err := s.giveRebate(ctx, session, goldParent.Id, agent.Id, orderId, productId, goldAmount, levelBonusInt, 3); err != nil {
|
||||
return errors.Wrapf(err, "给黄金上级返佣失败")
|
||||
}
|
||||
}
|
||||
// 超出最大限额的部分归平台(不需要记录)
|
||||
// 例如:剩余4元,给黄金3元,剩余1元归平台
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 情况C:既没有钻石上级,也没有黄金上级 ==========
|
||||
// 场景示例:
|
||||
// - 普通(推广人) -> 普通(直接上级) -> 普通 -> 普通...(整个链路都是普通代理)
|
||||
// - 普通(推广人) -> 普通(直接上级) -> 无上级(已经是最顶层)
|
||||
// 剩余金额全部归平台(不需要记录)
|
||||
return nil
|
||||
|
||||
default:
|
||||
// 未知等级,全部归平台
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// getRebateConfigFloat 获取返佣配置值(浮点数),如果配置不存在则返回默认值
|
||||
func (s *AgentService) getRebateConfigFloat(ctx context.Context, configKey string, defaultValue float64) (float64, error) {
|
||||
config, err := s.AgentConfigModel.FindOneByConfigKey(ctx, configKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
return defaultValue, err
|
||||
}
|
||||
value, err := strconv.ParseFloat(config.ConfigValue, 64)
|
||||
if err != nil {
|
||||
return defaultValue, errors.Wrapf(err, "解析配置值失败, key: %s, value: %s", configKey, config.ConfigValue)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// giveRebate 发放返佣
|
||||
@@ -350,6 +680,18 @@ func (s *AgentService) findDirectParent(ctx context.Context, agentId int64) (*mo
|
||||
}
|
||||
|
||||
// findDiamondParent 向上查找钻石上级
|
||||
//
|
||||
// 功能说明:从指定代理开始,向上逐级查找第一个钻石代理(Level == 3)
|
||||
//
|
||||
// 查找规则:
|
||||
// - 自动跳过所有普通代理(Level == 1)和黄金代理(Level == 2)
|
||||
// - 只返回第一个找到的钻石代理
|
||||
// - 如果没有找到钻石代理,返回 ErrNotFound
|
||||
//
|
||||
// 示例场景:
|
||||
// - 普通 -> 普通 -> 钻石:会找到钻石(跳过中间的普通代理)
|
||||
// - 普通 -> 黄金 -> 钻石:会找到钻石(跳过黄金代理)
|
||||
// - 普通 -> 普通 -> 黄金:返回 ErrNotFound(没有钻石)
|
||||
func (s *AgentService) findDiamondParent(ctx context.Context, agentId int64) (*model.Agent, error) {
|
||||
currentId := agentId
|
||||
maxDepth := 100 // 防止无限循环
|
||||
@@ -376,6 +718,20 @@ func (s *AgentService) findDiamondParent(ctx context.Context, agentId int64) (*m
|
||||
}
|
||||
|
||||
// findGoldParent 向上查找黄金上级
|
||||
//
|
||||
// 功能说明:从指定代理开始,向上逐级查找第一个黄金代理(Level == 2)
|
||||
//
|
||||
// 查找规则:
|
||||
// - 自动跳过所有普通代理(Level == 1)
|
||||
// - 如果先遇到钻石代理(Level == 3),不会跳过,但会继续查找黄金代理
|
||||
// 注意:在实际使用中,应该先调用 findDiamondParent,如果没找到钻石再调用此方法
|
||||
// - 只返回第一个找到的黄金代理
|
||||
// - 如果没有找到黄金代理,返回 ErrNotFound
|
||||
//
|
||||
// 示例场景:
|
||||
// - 普通 -> 普通 -> 黄金:会找到黄金(跳过中间的普通代理)
|
||||
// - 普通 -> 黄金:会找到黄金
|
||||
// - 普通 -> 普通 -> 钻石:返回 ErrNotFound(跳过钻石,继续查找黄金,但找不到)
|
||||
func (s *AgentService) findGoldParent(ctx context.Context, agentId int64) (*model.Agent, error) {
|
||||
currentId := agentId
|
||||
maxDepth := 100 // 防止无限循环
|
||||
@@ -446,7 +802,7 @@ func (s *AgentService) ProcessUpgrade(ctx context.Context, agentId, toLevel int6
|
||||
|
||||
if parent != nil && rebateAmount > 0 {
|
||||
// 返佣给原直接上级
|
||||
if err := s.giveRebateForUpgrade(transCtx, session, parent.Id, agentId, rebateAmount); err != nil {
|
||||
if err := s.giveRebateForUpgrade(transCtx, session, parent.Id, agentId, rebateAmount, orderNo); err != nil {
|
||||
return errors.Wrapf(err, "返佣给上级失败")
|
||||
}
|
||||
}
|
||||
@@ -462,10 +818,28 @@ func (s *AgentService) ProcessUpgrade(ctx context.Context, agentId, toLevel int6
|
||||
}
|
||||
|
||||
if needDetach {
|
||||
// 脱离前先获取原直接上级及其上级的信息(用于后续重新连接)
|
||||
oldParent, oldParentErr := s.findDirectParent(transCtx, agentId)
|
||||
var grandparentId int64 = 0
|
||||
if oldParentErr == nil && oldParent != nil {
|
||||
// 查找原上级的上级
|
||||
grandparent, grandparentErr := s.findDirectParent(transCtx, oldParent.Id)
|
||||
if grandparentErr == nil && grandparent != nil {
|
||||
grandparentId = grandparent.Id
|
||||
}
|
||||
}
|
||||
|
||||
// 脱离直接上级关系
|
||||
if err := s.detachFromParent(transCtx, session, agentId); err != nil {
|
||||
return errors.Wrapf(err, "脱离直接上级关系失败")
|
||||
}
|
||||
|
||||
// 脱离后,尝试连接到原上级的上级
|
||||
if grandparentId > 0 {
|
||||
if err := s.reconnectToGrandparent(transCtx, session, agentId, toLevel, grandparentId); err != nil {
|
||||
return errors.Wrapf(err, "重新连接上级关系失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 如果升级为钻石,独立成新团队
|
||||
@@ -515,14 +889,10 @@ func (s *AgentService) needDetachFromParent(ctx context.Context, agent *model.Ag
|
||||
|
||||
// 规则2:同级不能作为上下级(除了普通代理)
|
||||
if newLevel == parent.Level {
|
||||
if newLevel == 2 || newLevel == 3 { // 黄金或钻石
|
||||
if newLevel == 2 || newLevel == 3 { // 黄金或钻石同级需要脱离
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 规则3:钻石 → 黄金禁止(特殊规则)
|
||||
if newLevel == 2 && parent.Level == 3 {
|
||||
return true, nil
|
||||
// 普通代理同级(newLevel == 1 && parent.Level == 1)不需要脱离
|
||||
}
|
||||
|
||||
return false, nil
|
||||
@@ -555,6 +925,60 @@ func (s *AgentService) detachFromParent(ctx context.Context, session sqlx.Sessio
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconnectToGrandparent 重新连接到原上级的上级(如果存在且符合条件)
|
||||
func (s *AgentService) reconnectToGrandparent(ctx context.Context, session sqlx.Session, agentId int64, newLevel int64, grandparentId int64) error {
|
||||
// 获取原上级的上级信息
|
||||
grandparent, err := s.AgentModel.FindOne(ctx, grandparentId)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// 原上级的上级不存在,不需要重新连接
|
||||
return nil
|
||||
}
|
||||
return errors.Wrapf(err, "查询原上级的上级失败")
|
||||
}
|
||||
|
||||
// 验证是否可以连接到原上级的上级
|
||||
// 规则:新等级必须低于或等于原上级的上级等级,且不能同级(除了普通代理)
|
||||
if newLevel > grandparent.Level {
|
||||
// 新等级高于原上级的上级,不能连接
|
||||
return nil
|
||||
}
|
||||
|
||||
// 同级不能作为上下级(除了普通代理)
|
||||
if newLevel == grandparent.Level {
|
||||
if newLevel == 2 || newLevel == 3 {
|
||||
// 黄金或钻石同级不能连接
|
||||
return nil
|
||||
}
|
||||
// 普通代理同级可以连接(虽然这种情况不太可能发生)
|
||||
}
|
||||
|
||||
// 检查是否已经存在关系
|
||||
builder := s.AgentRelationModel.SelectBuilder().
|
||||
Where("parent_id = ? AND child_id = ? AND relation_type = ? AND del_state = ?", grandparent.Id, agentId, 1, globalkey.DelStateNo)
|
||||
existingRelations, err := s.AgentRelationModel.FindAll(ctx, builder, "")
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "查询现有关系失败")
|
||||
}
|
||||
|
||||
if len(existingRelations) > 0 {
|
||||
// 关系已存在,不需要重复创建
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建新的关系连接到原上级的上级
|
||||
relation := &model.AgentRelation{
|
||||
ParentId: grandparent.Id,
|
||||
ChildId: agentId,
|
||||
RelationType: 1, // 直接关系
|
||||
}
|
||||
if _, err := s.AgentRelationModel.Insert(ctx, session, relation); err != nil {
|
||||
return errors.Wrapf(err, "创建新关系失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateChildrenTeamLeader 更新所有下级的团队首领
|
||||
func (s *AgentService) updateChildrenTeamLeader(ctx context.Context, session sqlx.Session, agentId, teamLeaderId int64) error {
|
||||
// 递归更新所有下级
|
||||
@@ -604,7 +1028,8 @@ func (s *AgentService) findTeamLeaderId(ctx context.Context, agentId int64) (int
|
||||
}
|
||||
|
||||
// giveRebateForUpgrade 发放升级返佣
|
||||
func (s *AgentService) giveRebateForUpgrade(ctx context.Context, session sqlx.Session, parentAgentId, upgradeAgentId int64, amount float64) error {
|
||||
// 注意:升级返佣信息记录在 agent_upgrade 表中(rebate_agent_id 和 rebate_amount),不需要在 agent_rebate 表中创建记录
|
||||
func (s *AgentService) giveRebateForUpgrade(ctx context.Context, session sqlx.Session, parentAgentId, upgradeAgentId int64, amount float64, orderNo string) error {
|
||||
// 更新钱包余额
|
||||
wallet, err := s.AgentWalletModel.FindOneByAgentId(ctx, parentAgentId)
|
||||
if err != nil {
|
||||
@@ -621,21 +1046,25 @@ func (s *AgentService) giveRebateForUpgrade(ctx context.Context, session sqlx.Se
|
||||
}
|
||||
|
||||
// GetUpgradeFee 获取升级费用
|
||||
func (s *AgentService) GetUpgradeFee(fromLevel, toLevel int64) float64 {
|
||||
func (s *AgentService) GetUpgradeFee(ctx context.Context, fromLevel, toLevel int64) (float64, error) {
|
||||
if fromLevel == 1 && toLevel == 2 {
|
||||
return 199 // 普通→黄金
|
||||
// 普通→黄金:从配置获取
|
||||
return s.getRebateConfigFloat(ctx, "upgrade_to_gold_fee", 199)
|
||||
} else if toLevel == 3 {
|
||||
return 980 // 升级为钻石
|
||||
// 升级为钻石:从配置获取
|
||||
return s.getRebateConfigFloat(ctx, "upgrade_to_diamond_fee", 980)
|
||||
}
|
||||
return 0
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// GetUpgradeRebate 获取升级返佣金额
|
||||
func (s *AgentService) GetUpgradeRebate(fromLevel, toLevel int64) float64 {
|
||||
func (s *AgentService) GetUpgradeRebate(ctx context.Context, fromLevel, toLevel int64) (float64, error) {
|
||||
if fromLevel == 1 && toLevel == 2 {
|
||||
return 139 // 普通→黄金返佣
|
||||
// 普通→黄金返佣:从配置获取
|
||||
return s.getRebateConfigFloat(ctx, "upgrade_to_gold_rebate", 139)
|
||||
} else if toLevel == 3 {
|
||||
return 680 // 升级为钻石返佣
|
||||
// 升级为钻石返佣:从配置获取
|
||||
return s.getRebateConfigFloat(ctx, "upgrade_to_diamond_rebate", 680)
|
||||
}
|
||||
return 0
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ func (s *ApiRegistryService) generateApiName(path string) string {
|
||||
"order": "订单管理",
|
||||
"platform_user": "平台用户",
|
||||
"product": "产品管理",
|
||||
"promotion": "推广管理",
|
||||
"query": "查询管理",
|
||||
"role": "角色管理",
|
||||
"user": "用户管理",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
"ycc-server/app/main/api/internal/config"
|
||||
"ycc-server/app/main/api/internal/types"
|
||||
"encoding/json"
|
||||
@@ -58,3 +59,65 @@ func (s *AsynqService) SendQueryTask(orderID int64) error {
|
||||
logx.Infof("发送异步任务成功,任务ID: %s, 队列: %s, 订单号: %d", info.ID, info.Queue, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAgentProcessTask 发送代理处理任务
|
||||
func (s *AsynqService) SendAgentProcessTask(orderID int64) error {
|
||||
// 准备任务的 payload
|
||||
payload := types.MsgAgentProcessPayload{
|
||||
OrderID: orderID,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
logx.Errorf("发送代理处理任务失败 (无法编码 payload): %v, 订单号: %d", err, orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
options := []asynq.Option{
|
||||
asynq.MaxRetry(5), // 设置最大重试次数
|
||||
}
|
||||
// 创建任务
|
||||
task := asynq.NewTask(types.MsgAgentProcess, payloadBytes, options...)
|
||||
|
||||
// 将任务加入队列并获取任务信息
|
||||
info, err := s.client.Enqueue(task)
|
||||
if err != nil {
|
||||
logx.Errorf("发送代理处理任务失败 (加入队列失败): %+v, 订单号: %d", err, orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录成功日志,带上任务 ID 和队列信息
|
||||
logx.Infof("发送代理处理任务成功,任务ID: %s, 队列: %s, 订单号: %d", info.ID, info.Queue, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendUnfreezeTask 发送解冻任务(延迟执行)
|
||||
func (s *AsynqService) SendUnfreezeTask(freezeTaskId int64, processAt time.Time) error {
|
||||
// 准备任务的 payload
|
||||
payload := types.MsgUnfreezeCommissionPayload{
|
||||
FreezeTaskId: freezeTaskId,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
logx.Errorf("发送解冻任务失败 (无法编码 payload): %v, 冻结任务ID: %d", err, freezeTaskId)
|
||||
return err
|
||||
}
|
||||
|
||||
options := []asynq.Option{
|
||||
asynq.MaxRetry(5), // 设置最大重试次数
|
||||
asynq.ProcessAt(processAt), // 延迟到指定时间执行
|
||||
asynq.Queue("critical"), // 使用关键队列
|
||||
}
|
||||
// 创建任务
|
||||
task := asynq.NewTask(types.MsgUnfreezeCommission, payloadBytes, options...)
|
||||
|
||||
// 将任务加入队列并获取任务信息
|
||||
info, err := s.client.Enqueue(task)
|
||||
if err != nil {
|
||||
logx.Errorf("发送解冻任务失败 (加入队列失败): %+v, 冻结任务ID: %d", err, freezeTaskId)
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录成功日志,带上任务 ID 和队列信息
|
||||
logx.Infof("发送解冻任务成功,任务ID: %s, 队列: %s, 冻结任务ID: %d, 执行时间: %v", info.ID, info.Queue, freezeTaskId, processAt)
|
||||
return nil
|
||||
}
|
||||
@@ -28,8 +28,8 @@ func NewAuthorizationService(c config.Config, authDocModel model.AuthorizationDo
|
||||
return &AuthorizationService{
|
||||
config: c,
|
||||
authDocModel: authDocModel,
|
||||
fileStoragePath: "data/authorization_docs", // 使用相对路径,兼容开发环境
|
||||
fileBaseURL: c.Authorization.FileBaseURL, // 从配置文件读取
|
||||
fileStoragePath: "data/authorization_docs", // 使用相对路径,兼容开发环境
|
||||
fileBaseURL: c.Authorization.FileBaseURL, // 从配置文件读取
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func (s *AuthorizationService) generatePDFContent(userInfo map[string]interface{
|
||||
"/app/static/SIMHEI.TTF", // Docker容器内的字体文件
|
||||
"app/main/api/static/SIMHEI.TTF", // 开发环境备用路径
|
||||
}
|
||||
|
||||
|
||||
// 尝试添加字体
|
||||
fontAdded := false
|
||||
for _, fontPath := range fontPaths {
|
||||
@@ -150,19 +150,19 @@ func (s *AuthorizationService) generatePDFContent(userInfo map[string]interface{
|
||||
pdf.SetFont("Arial", "", 20)
|
||||
}
|
||||
pdf.CellFormat(0, 15, "授权书", "", 1, "C", false, 0, "")
|
||||
|
||||
|
||||
// 添加空行
|
||||
pdf.Ln(5)
|
||||
|
||||
|
||||
// 设置正文样式 - 正常字体
|
||||
if fontAdded {
|
||||
pdf.SetFont("ChineseFont", "", 12)
|
||||
} else {
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
|
||||
|
||||
// 构建授权书内容(去掉标题部分)
|
||||
content := fmt.Sprintf(`海南省学宇思网络科技有限公司:
|
||||
content := fmt.Sprintf(`海南海宇大数据有限公司:
|
||||
本人%s拟向贵司申请大数据分析报告查询业务,贵司需要了解本人相关状况,用于查询大数据分析报告,因此本人同意向贵司提供本人的姓名和手机号等个人信息,并同意贵司向第三方(包括但不限于西部数据交易有限公司)传送上述信息。第三方将使用上述信息核实信息真实情况,查询信用记录,并生成报告。
|
||||
|
||||
授权内容如下:
|
||||
@@ -185,8 +185,8 @@ func (s *AuthorizationService) generatePDFContent(userInfo map[string]interface{
|
||||
附加说明:
|
||||
本人在授权的相关数据将依据法律法规及贵司内部数据管理规范妥善存储,存储期限为法律要求的最短必要时间。超过存储期限或在数据使用目的达成后,贵司将对相关数据进行销毁或匿名化处理。
|
||||
本人有权随时撤回本授权书中的授权,但撤回前的授权行为及其法律后果仍具有法律效力。若需撤回授权,本人可通过贵司官方渠道提交书面申请,贵司将在收到申请后依法停止对本人数据的使用。
|
||||
你通过"一查查",自愿支付相应费用,用于购买海南省学宇思网络科技有限公司的大数据报告产品。如若对产品内容存在异议,可通过邮箱admin@iieeii.com或APP"联系客服"按钮进行反馈,贵司将在收到异议之日起20日内进行核查和处理,并将结果答复。
|
||||
你向海南省学宇思网络科技有限公司的支付方式为:海南省学宇思网络科技有限公司及其经官方授权的相关企业的支付宝账户。
|
||||
你通过"一查查",自愿支付相应费用,用于购买海南海宇大数据有限公司的大数据报告产品。如若对产品内容存在异议,可通过邮箱admin@iieeii.com或APP"联系客服"按钮进行反馈,贵司将在收到异议之日起20日内进行核查和处理,并将结果答复。
|
||||
你向海南海宇大数据有限公司的支付方式为:海南海宇大数据有限公司及其经官方授权的相关企业的支付宝账户。
|
||||
|
||||
争议解决机制:
|
||||
若因本授权书引发争议,双方应友好协商解决;协商不成的,双方同意将争议提交至授权书签署地(海南省)有管辖权的人民法院解决。
|
||||
|
||||
@@ -1,670 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"ycc-server/app/main/api/internal/config"
|
||||
"ycc-server/app/main/model"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/zeromicro/go-zero/core/stores/sqlx"
|
||||
)
|
||||
|
||||
// mockResult 模拟sql.Result
|
||||
type mockResult struct {
|
||||
lastInsertId int64
|
||||
rowsAffected int64
|
||||
}
|
||||
|
||||
func (m *mockResult) LastInsertId() (int64, error) {
|
||||
return m.lastInsertId, nil
|
||||
}
|
||||
|
||||
func (m *mockResult) RowsAffected() (int64, error) {
|
||||
return m.rowsAffected, nil
|
||||
}
|
||||
|
||||
// MockAuthorizationDocumentModel 模拟授权书模型
|
||||
type MockAuthorizationDocumentModel struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) Insert(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) (sql.Result, error) {
|
||||
args := m.Called(ctx, session, data)
|
||||
return args.Get(0).(sql.Result), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindOne(ctx context.Context, id int64) (*model.AuthorizationDocument, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.AuthorizationDocument), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) Update(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) (sql.Result, error) {
|
||||
args := m.Called(ctx, session, data)
|
||||
return args.Get(0).(sql.Result), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) UpdateWithVersion(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) error {
|
||||
args := m.Called(ctx, session, data)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) Trans(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
|
||||
args := m.Called(ctx, fn)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) SelectBuilder() squirrel.SelectBuilder {
|
||||
args := m.Called()
|
||||
return args.Get(0).(squirrel.SelectBuilder)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) DeleteSoft(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) error {
|
||||
args := m.Called(ctx, session, data)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindSum(ctx context.Context, sumBuilder squirrel.SelectBuilder, field string) (float64, error) {
|
||||
args := m.Called(ctx, sumBuilder, field)
|
||||
return args.Get(0).(float64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindCount(ctx context.Context, countBuilder squirrel.SelectBuilder, field string) (int64, error) {
|
||||
args := m.Called(ctx, countBuilder, field)
|
||||
return args.Get(0).(int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindAll(ctx context.Context, rowBuilder squirrel.SelectBuilder, orderBy string) ([]*model.AuthorizationDocument, error) {
|
||||
args := m.Called(ctx, rowBuilder, orderBy)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindPageListByPage(ctx context.Context, rowBuilder squirrel.SelectBuilder, page, pageSize int64, orderBy string) ([]*model.AuthorizationDocument, error) {
|
||||
args := m.Called(ctx, rowBuilder, page, pageSize, orderBy)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindPageListByPageWithTotal(ctx context.Context, rowBuilder squirrel.SelectBuilder, page, pageSize int64, orderBy string) ([]*model.AuthorizationDocument, int64, error) {
|
||||
args := m.Called(ctx, rowBuilder, page, pageSize, orderBy)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
return args.Get(0).([]*model.AuthorizationDocument), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindPageListByIdDESC(ctx context.Context, rowBuilder squirrel.SelectBuilder, preMinId, pageSize int64) ([]*model.AuthorizationDocument, error) {
|
||||
args := m.Called(ctx, rowBuilder, preMinId, pageSize)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindPageListByIdASC(ctx context.Context, rowBuilder squirrel.SelectBuilder, preMaxId, pageSize int64) ([]*model.AuthorizationDocument, error) {
|
||||
args := m.Called(ctx, rowBuilder, preMaxId, pageSize)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) Delete(ctx context.Context, session sqlx.Session, id int64) error {
|
||||
args := m.Called(ctx, session, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAuthorizationDocumentModel) FindByOrderId(ctx context.Context, orderId int64) ([]*model.AuthorizationDocument, error) {
|
||||
args := m.Called(ctx, orderId)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
|
||||
}
|
||||
|
||||
// MockResult 模拟数据库结果
|
||||
type MockResult struct {
|
||||
lastInsertId int64
|
||||
}
|
||||
|
||||
func (m *MockResult) LastInsertId() (int64, error) {
|
||||
return m.lastInsertId, nil
|
||||
}
|
||||
|
||||
func (m *MockResult) RowsAffected() (int64, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// TestNewAuthorizationService 测试创建授权书服务
|
||||
func TestNewAuthorizationService(t *testing.T) {
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
assert.NotNil(t, service)
|
||||
assert.Equal(t, "data/authorization_docs", service.fileStoragePath)
|
||||
assert.Equal(t, "https://test.com/api/v1/auth-docs", service.fileBaseURL)
|
||||
}
|
||||
|
||||
// TestGetFullFileURL 测试获取完整文件URL
|
||||
func TestGetFullFileURL(t *testing.T) {
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
relativePath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "正常相对路径",
|
||||
relativePath: "2025/09/auth_123_456_20250913_160800.pdf",
|
||||
expected: "https://test.com/api/v1/auth-docs/2025/09/auth_123_456_20250913_160800.pdf",
|
||||
},
|
||||
{
|
||||
name: "空路径",
|
||||
relativePath: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "只有文件名",
|
||||
relativePath: "test.pdf",
|
||||
expected: "https://test.com/api/v1/auth-docs/test.pdf",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := service.GetFullFileURL(tt.relativePath)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAuthorizationDocument 测试生成授权书
|
||||
func TestGenerateAuthorizationDocument(t *testing.T) {
|
||||
// 创建测试配置
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
|
||||
// 创建模拟的数据库模型
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
|
||||
// 创建授权书服务
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
// 准备测试数据
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "张三",
|
||||
"id_card": "110101199001011234",
|
||||
"mobile": "13800138000",
|
||||
}
|
||||
|
||||
// 模拟数据库插入成功
|
||||
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
&mockResult{lastInsertId: 1, rowsAffected: 1}, nil)
|
||||
|
||||
// 执行测试
|
||||
authDoc, err := service.GenerateAuthorizationDocument(
|
||||
context.Background(),
|
||||
1, // userID
|
||||
2, // orderID
|
||||
3, // queryID
|
||||
userInfo,
|
||||
)
|
||||
|
||||
// 验证结果
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, authDoc)
|
||||
assert.Equal(t, int64(1), authDoc.UserId)
|
||||
assert.Equal(t, int64(2), authDoc.OrderId)
|
||||
assert.Equal(t, int64(3), authDoc.QueryId)
|
||||
assert.Equal(t, "pdf", authDoc.FileType)
|
||||
assert.Equal(t, "active", authDoc.Status)
|
||||
assert.False(t, authDoc.ExpireTime.Valid) // 永久保留,不设置过期时间
|
||||
|
||||
// 验证文件路径格式(兼容Windows和Unix路径分隔符)
|
||||
assert.True(t, strings.Contains(authDoc.FilePath, "data/authorization_docs") ||
|
||||
strings.Contains(authDoc.FilePath, "data\\authorization_docs"))
|
||||
assert.Contains(t, authDoc.FileName, "auth_")
|
||||
assert.Contains(t, authDoc.FileName, ".pdf")
|
||||
|
||||
// 验证相对路径格式
|
||||
assert.Regexp(t, `^\d{4}/\d{2}/auth_\d+_\d+_\d{8}_\d{6}\.pdf$`, authDoc.FileUrl)
|
||||
|
||||
// 验证文件大小
|
||||
assert.Greater(t, authDoc.FileSize, int64(0))
|
||||
|
||||
// 验证数据库调用
|
||||
mockModel.AssertExpectations(t)
|
||||
|
||||
// 验证文件是否真的被创建
|
||||
if _, err := os.Stat(authDoc.FilePath); err == nil {
|
||||
t.Logf("✅ 授权书文件已创建: %s", authDoc.FilePath)
|
||||
t.Logf("📊 文件大小: %d 字节", authDoc.FileSize)
|
||||
} else {
|
||||
t.Logf("⚠️ 文件未找到: %s", authDoc.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAuthorizationDocument_DatabaseError 测试数据库错误
|
||||
func TestGenerateAuthorizationDocument_DatabaseError(t *testing.T) {
|
||||
// 创建测试配置
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
|
||||
// 创建模拟的数据库模型
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
|
||||
// 创建授权书服务
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
// 准备测试数据
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "李四",
|
||||
"id_card": "110101199001011235",
|
||||
"mobile": "13800138001",
|
||||
}
|
||||
|
||||
// 模拟数据库插入失败
|
||||
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
(*mockResult)(nil), errors.New("数据库连接失败"))
|
||||
|
||||
// 执行测试
|
||||
authDoc, err := service.GenerateAuthorizationDocument(
|
||||
context.Background(),
|
||||
1, // userID
|
||||
2, // orderID
|
||||
3, // queryID
|
||||
userInfo,
|
||||
)
|
||||
|
||||
// 验证结果
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, authDoc)
|
||||
assert.Contains(t, err.Error(), "数据库连接失败")
|
||||
|
||||
// 验证数据库调用
|
||||
mockModel.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestGeneratePDFContent 测试生成PDF内容
|
||||
func TestGeneratePDFContent(t *testing.T) {
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "张三",
|
||||
"id_card": "110101199001011234",
|
||||
}
|
||||
|
||||
pdfBytes, err := service.generatePDFContent(userInfo)
|
||||
|
||||
// 验证结果
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, pdfBytes)
|
||||
assert.Greater(t, len(pdfBytes), 0)
|
||||
|
||||
// 验证PDF内容(PDF是二进制格式,只验证基本结构)
|
||||
assert.Contains(t, string(pdfBytes), "%PDF") // PDF文件头
|
||||
|
||||
// 按照 GenerateAuthorizationDocument 的方式保存文件到本地
|
||||
// 1. 创建文件存储目录
|
||||
year := time.Now().Format("2006")
|
||||
month := time.Now().Format("01")
|
||||
dirPath := filepath.Join("data", "authorization_docs", year, month)
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
t.Fatalf("创建存储目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 生成文件名和路径
|
||||
fileName := fmt.Sprintf("test_auth_%s.pdf", time.Now().Format("20060102_150405"))
|
||||
filePath := filepath.Join(dirPath, fileName)
|
||||
relativePath := fmt.Sprintf("%s/%s/%s", year, month, fileName)
|
||||
|
||||
// 3. 保存PDF文件
|
||||
if err := os.WriteFile(filePath, pdfBytes, 0644); err != nil {
|
||||
t.Fatalf("保存PDF文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 4. 验证文件是否保存成功
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
t.Logf("✅ PDF文件已保存到本地")
|
||||
t.Logf("📄 文件名: %s", fileName)
|
||||
t.Logf("📁 文件路径: %s", filePath)
|
||||
t.Logf("🔗 相对路径: %s", relativePath)
|
||||
t.Logf("📊 文件大小: %d 字节", len(pdfBytes))
|
||||
|
||||
// 获取绝对路径
|
||||
absPath, _ := filepath.Abs(filePath)
|
||||
t.Logf("📍 绝对路径: %s", absPath)
|
||||
} else {
|
||||
t.Errorf("❌ PDF文件保存失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavePDFToLocal 专门测试保存PDF文件到本地
|
||||
func TestSavePDFToLocal(t *testing.T) {
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
// 准备测试数据
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "何志勇",
|
||||
"id_card": "452528197907133014",
|
||||
"mobile": "18276151590",
|
||||
}
|
||||
|
||||
// 生成PDF内容
|
||||
pdfBytes, err := service.generatePDFContent(userInfo)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, pdfBytes)
|
||||
assert.Greater(t, len(pdfBytes), 0)
|
||||
|
||||
// 按照 GenerateAuthorizationDocument 的方式保存文件到本地
|
||||
// 1. 创建文件存储目录
|
||||
year := time.Now().Format("2006")
|
||||
month := time.Now().Format("01")
|
||||
dirPath := filepath.Join("data", "authorization_docs", year, month)
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
t.Fatalf("创建存储目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 生成文件名和路径
|
||||
fileName := fmt.Sprintf("local_test_auth_%s.pdf", time.Now().Format("20060102_150405"))
|
||||
filePath := filepath.Join(dirPath, fileName)
|
||||
relativePath := fmt.Sprintf("%s/%s/%s", year, month, fileName)
|
||||
|
||||
// 3. 保存PDF文件
|
||||
if err := os.WriteFile(filePath, pdfBytes, 0644); err != nil {
|
||||
t.Fatalf("保存PDF文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 4. 验证文件是否保存成功
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
t.Logf("✅ PDF文件已保存到本地")
|
||||
t.Logf("📄 文件名: %s", fileName)
|
||||
t.Logf("📁 文件路径: %s", filePath)
|
||||
t.Logf("🔗 相对路径: %s", relativePath)
|
||||
t.Logf("📊 文件大小: %d 字节", len(pdfBytes))
|
||||
|
||||
// 获取绝对路径
|
||||
absPath, _ := filepath.Abs(filePath)
|
||||
t.Logf("📍 绝对路径: %s", absPath)
|
||||
|
||||
// 验证文件内容
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, fileInfo.Size(), int64(1000)) // 文件应该大于1KB
|
||||
|
||||
t.Logf("🎉 文件保存验证通过!")
|
||||
} else {
|
||||
t.Errorf("❌ PDF文件保存失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratePDFContent_EmptyUserInfo 测试空用户信息
|
||||
func TestGeneratePDFContent_EmptyUserInfo(t *testing.T) {
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
userInfo := map[string]interface{}{}
|
||||
|
||||
pdfBytes, err := service.generatePDFContent(userInfo)
|
||||
|
||||
// 验证结果
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, pdfBytes)
|
||||
assert.Greater(t, len(pdfBytes), 0)
|
||||
|
||||
// 验证PDF内容(PDF是二进制格式,只验证基本结构)
|
||||
assert.Contains(t, string(pdfBytes), "%PDF") // PDF文件头
|
||||
}
|
||||
|
||||
// TestGetUserInfoString 测试获取用户信息字符串
|
||||
func TestGetUserInfoString(t *testing.T) {
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "张三",
|
||||
"id_card": "110101199001011234",
|
||||
"age": 30,
|
||||
"empty": "",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "正常字符串",
|
||||
key: "name",
|
||||
expected: "张三",
|
||||
},
|
||||
{
|
||||
name: "身份证号",
|
||||
key: "id_card",
|
||||
expected: "110101199001011234",
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
key: "empty",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "不存在的键",
|
||||
key: "not_exist",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "非字符串类型",
|
||||
key: "age",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getUserInfoString(userInfo, tt.key)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGeneratePDFContent 性能测试
|
||||
func BenchmarkGeneratePDFContent(b *testing.B) {
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "张三",
|
||||
"id_card": "110101199001011234",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := service.generatePDFContent(userInfo)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizationService_Integration 集成测试(需要真实文件系统)
|
||||
func TestAuthorizationService_Integration(t *testing.T) {
|
||||
// 创建测试配置
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
|
||||
// 创建模拟的数据库模型
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
|
||||
// 创建授权书服务
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
// 准备测试数据
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "王五",
|
||||
"id_card": "110101199001011236",
|
||||
"mobile": "13800138002",
|
||||
}
|
||||
|
||||
// 模拟数据库插入成功
|
||||
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
&mockResult{lastInsertId: 1, rowsAffected: 1}, nil)
|
||||
|
||||
// 执行测试
|
||||
authDoc, err := service.GenerateAuthorizationDocument(
|
||||
context.Background(),
|
||||
1, // userID
|
||||
2, // orderID
|
||||
3, // queryID
|
||||
userInfo,
|
||||
)
|
||||
|
||||
// 验证结果
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, authDoc)
|
||||
|
||||
// 测试GetFullFileURL方法
|
||||
fullURL := service.GetFullFileURL(authDoc.FileUrl)
|
||||
expectedURL := fmt.Sprintf("%s/%s", config.Authorization.FileBaseURL, authDoc.FileUrl)
|
||||
assert.Equal(t, expectedURL, fullURL)
|
||||
|
||||
// 验证文件是否真的被创建
|
||||
if _, err := os.Stat(authDoc.FilePath); err == nil {
|
||||
t.Logf("✅ 集成测试成功 - 授权书文件已创建")
|
||||
t.Logf("📄 文件名: %s", authDoc.FileName)
|
||||
t.Logf("📁 文件路径: %s", authDoc.FilePath)
|
||||
t.Logf("🔗 相对路径: %s", authDoc.FileUrl)
|
||||
t.Logf("🌐 完整URL: %s", fullURL)
|
||||
t.Logf("📊 文件大小: %d 字节", authDoc.FileSize)
|
||||
} else {
|
||||
t.Logf("⚠️ 集成测试 - 文件未找到: %s", authDoc.FilePath)
|
||||
}
|
||||
|
||||
// 验证数据库调用
|
||||
mockModel.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestGeneratePDFFile 专门测试PDF文件生成
|
||||
func TestGeneratePDFFile(t *testing.T) {
|
||||
// 创建测试配置
|
||||
config := config.Config{
|
||||
Authorization: config.AuthorizationConfig{
|
||||
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
|
||||
},
|
||||
}
|
||||
|
||||
// 创建模拟的数据库模型
|
||||
mockModel := &MockAuthorizationDocumentModel{}
|
||||
|
||||
// 创建授权书服务
|
||||
service := NewAuthorizationService(config, mockModel)
|
||||
|
||||
// 准备测试数据
|
||||
userInfo := map[string]interface{}{
|
||||
"name": "测试用户",
|
||||
"id_card": "110101199001011237",
|
||||
"mobile": "13800138003",
|
||||
}
|
||||
|
||||
// 模拟数据库插入成功
|
||||
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
|
||||
&mockResult{lastInsertId: 1, rowsAffected: 1}, nil)
|
||||
|
||||
// 执行测试
|
||||
authDoc, err := service.GenerateAuthorizationDocument(
|
||||
context.Background(),
|
||||
999, // userID
|
||||
888, // orderID
|
||||
777, // queryID
|
||||
userInfo,
|
||||
)
|
||||
|
||||
// 验证结果
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, authDoc)
|
||||
|
||||
// 验证文件是否真的被创建
|
||||
if _, err := os.Stat(authDoc.FilePath); err == nil {
|
||||
t.Logf("✅ PDF文件生成成功!")
|
||||
t.Logf("📄 文件名: %s", authDoc.FileName)
|
||||
t.Logf("📁 文件路径: %s", authDoc.FilePath)
|
||||
t.Logf("🔗 相对路径: %s", authDoc.FileUrl)
|
||||
t.Logf("📊 文件大小: %d 字节", authDoc.FileSize)
|
||||
|
||||
// 验证文件内容
|
||||
fileInfo, err := os.Stat(authDoc.FilePath)
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, fileInfo.Size(), int64(1000)) // 文件应该大于1KB
|
||||
|
||||
// 验证文件名格式
|
||||
assert.Regexp(t, `^auth_999_888_\d{8}_\d{6}\.pdf$`, authDoc.FileName)
|
||||
|
||||
// 验证路径格式
|
||||
assert.Regexp(t, `^\d{4}/\d{2}/auth_999_888_\d{8}_\d{6}\.pdf$`, authDoc.FileUrl)
|
||||
|
||||
t.Logf("🎉 所有验证通过!")
|
||||
} else {
|
||||
t.Errorf("❌ PDF文件未创建: %s", authDoc.FilePath)
|
||||
}
|
||||
|
||||
// 验证数据库调用
|
||||
mockModel.AssertExpectations(t)
|
||||
}
|
||||
Reference in New Issue
Block a user