From 22f19377a53fc3d99e50b4aadae4532def28be9f Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 31 Jan 2026 12:01:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(recharge):=20=E6=96=B0=E5=A2=9E=E5=85=85?= =?UTF-8?q?=E5=80=BC=E6=9C=8D=E5=8A=A1=E5=92=8C=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 RechargeService 完整充值业务逻辑 - 创建充值订单、预检强充要求 - 支付回调处理、幂等性检查 - 累计充值更新、一次性佣金触发 - 新增 RechargeDTO 请求/响应结构 - CreateRechargeRequest、RechargeResponse - RechargeListRequest/Response、RechargeCheckRequest/Response - 完整的单元测试覆盖(1488 行) - 强充要求检查、支付回调、佣金发放等场景 - 事务处理、幂等性验证 Co-authored-by: Sisyphus --- internal/model/dto/recharge.go | 107 ++ internal/service/recharge/service.go | 724 ++++++++++ internal/service/recharge/service_test.go | 1488 +++++++++++++++++++++ 3 files changed, 2319 insertions(+) create mode 100644 internal/model/dto/recharge.go create mode 100644 internal/service/recharge/service.go create mode 100644 internal/service/recharge/service_test.go diff --git a/internal/model/dto/recharge.go b/internal/model/dto/recharge.go new file mode 100644 index 0000000..e7a233f --- /dev/null +++ b/internal/model/dto/recharge.go @@ -0,0 +1,107 @@ +package dto + +import "time" + +// CreateRechargeRequest 创建充值订单请求 +type CreateRechargeRequest struct { + // 资源类型: iot_card-物联网卡 device-设备 + ResourceType string `json:"resource_type" validate:"required,oneof=iot_card device" description:"资源类型"` + // 资源ID(卡ID或设备ID) + ResourceID uint `json:"resource_id" validate:"required,min=1" description:"资源ID"` + // 充值金额(分) + Amount int64 `json:"amount" validate:"required,min=100,max=10000000" description:"充值金额(分)"` + // 支付方式: wechat-微信 alipay-支付宝 + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" description:"支付方式"` +} + +// RechargeResponse 充值订单响应 +type RechargeResponse struct { + // 充值订单ID + ID uint `json:"id" description:"充值订单ID"` + // 充值订单号 + RechargeNo string `json:"recharge_no" description:"充值订单号"` + // 用户ID + UserID uint `json:"user_id" description:"用户ID"` + // 钱包ID + WalletID uint `json:"wallet_id" description:"钱包ID"` + // 充值金额(分) + Amount int64 `json:"amount" description:"充值金额(分)"` + // 支付方式 + PaymentMethod string `json:"payment_method" description:"支付方式"` + // 支付渠道 + PaymentChannel *string `json:"payment_channel,omitempty" description:"支付渠道"` + // 第三方支付交易号 + PaymentTransactionID *string `json:"payment_transaction_id,omitempty" description:"第三方支付交易号"` + // 充值状态: 1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 + Status int `json:"status" description:"充值状态"` + // 状态文本 + StatusText string `json:"status_text" description:"状态文本"` + // 支付时间 + PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` + // 完成时间 + CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"` + // 创建时间 + CreatedAt time.Time `json:"created_at" description:"创建时间"` + // 更新时间 + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// RechargeListRequest 充值订单列表请求 +type RechargeListRequest struct { + // 页码(从1开始) + Page int `json:"page" form:"page" description:"页码"` + // 每页数量 + PageSize int `json:"page_size" form:"page_size" description:"每页数量"` + // 钱包ID筛选 + WalletID *uint `json:"wallet_id" form:"wallet_id" description:"钱包ID"` + // 状态筛选 + Status *int `json:"status" form:"status" description:"状态"` + // 开始时间 + StartTime *time.Time `json:"start_time" form:"start_time" description:"开始时间"` + // 结束时间 + EndTime *time.Time `json:"end_time" form:"end_time" description:"结束时间"` +} + +// RechargeListResponse 充值订单列表响应 +type RechargeListResponse struct { + // 列表数据 + List []*RechargeResponse `json:"list" description:"列表数据"` + // 总记录数 + Total int64 `json:"total" description:"总记录数"` + // 当前页码 + Page int `json:"page" description:"当前页码"` + // 每页数量 + PageSize int `json:"page_size" description:"每页数量"` + // 总页数 + TotalPages int `json:"total_pages" description:"总页数"` +} + +// RechargeCheckRequest 充值预检请求 +type RechargeCheckRequest struct { + // 资源类型: iot_card-物联网卡 device-设备 + ResourceType string `json:"resource_type" query:"resource_type" validate:"required,oneof=iot_card device" description:"资源类型"` + // 资源ID(卡ID或设备ID) + ResourceID uint `json:"resource_id" query:"resource_id" validate:"required,min=1" description:"资源ID"` +} + +// RechargeCheckResponse 充值预检响应 +type RechargeCheckResponse struct { + // 是否需要强充 + NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强充"` + // 强充金额(分) + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"` + // 触发类型: single_recharge-单次充值 accumulated_recharge-累计充值 + TriggerType string `json:"trigger_type" description:"触发类型"` + // 最小充值金额(分) + MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"` + // 最大充值金额(分) + MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"` + // 当前累计充值金额(分) + CurrentAccumulated int64 `json:"current_accumulated" description:"当前累计充值金额(分)"` + // 佣金触发阈值(分) + Threshold int64 `json:"threshold" description:"佣金触发阈值(分)"` + // 提示信息 + Message string `json:"message" description:"提示信息"` + // 一次性佣金是否已发放 + FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"` +} diff --git a/internal/service/recharge/service.go b/internal/service/recharge/service.go new file mode 100644 index 0000000..7d5068c --- /dev/null +++ b/internal/service/recharge/service.go @@ -0,0 +1,724 @@ +package recharge + +import ( + "context" + "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" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ForceRechargeRequirement 强充要求信息 +type ForceRechargeRequirement struct { + NeedForceRecharge bool `json:"need_force_recharge"` // 是否需要强充 + ForceRechargeAmount int64 `json:"force_recharge_amount"` // 强充金额(分) + TriggerType string `json:"trigger_type"` // 触发类型: single_recharge/accumulated_recharge + MinAmount int64 `json:"min_amount"` // 最小充值金额 + MaxAmount int64 `json:"max_amount"` // 最大充值金额 + CurrentAccumulated int64 `json:"current_accumulated"` // 当前累计充值 + Threshold int64 `json:"threshold"` // 佣金触发阈值 + Message string `json:"message"` // 提示信息 + FirstCommissionPaid bool `json:"first_commission_paid"` // 一次性佣金是否已发放 +} + +// Service 充值服务 +// 负责充值订单的创建、预检、支付回调处理等业务逻辑 +type Service struct { + db *gorm.DB + rechargeStore *postgres.RechargeStore + walletStore *postgres.WalletStore + walletTransactionStore *postgres.WalletTransactionStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + commissionRecordStore *postgres.CommissionRecordStore + logger *zap.Logger +} + +// New 创建充值服务实例 +func New( + db *gorm.DB, + rechargeStore *postgres.RechargeStore, + walletStore *postgres.WalletStore, + walletTransactionStore *postgres.WalletTransactionStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, + commissionRecordStore *postgres.CommissionRecordStore, + logger *zap.Logger, +) *Service { + return &Service{ + db: db, + rechargeStore: rechargeStore, + walletStore: walletStore, + walletTransactionStore: walletTransactionStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + shopSeriesAllocationStore: shopSeriesAllocationStore, + commissionRecordStore: commissionRecordStore, + logger: logger, + } +} + +// Create 创建充值订单 +// 验证资源、金额范围、强充要求,生成订单号 +func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, userID uint) (*dto.RechargeResponse, error) { + // 1. 验证金额范围 + if req.Amount < constants.RechargeMinAmount { + return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能低于1元") + } + if req.Amount > constants.RechargeMaxAmount { + return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能超过100000元") + } + + // 2. 获取资源(卡或设备) + var wallet *model.Wallet + var err error + + if req.ResourceType == "iot_card" { + wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "iot_card", req.ResourceID, "main") + } else if req.ResourceType == "device" { + wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "device", req.ResourceID, "main") + } else { + return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型") + } + + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") + } + + // 3. 验证强充要求 + forceReq, err := s.checkForceRechargeRequirement(ctx, req.ResourceType, req.ResourceID) + if err != nil { + return nil, err + } + + if forceReq.NeedForceRecharge && req.Amount != forceReq.ForceRechargeAmount { + return nil, errors.New(errors.CodeForceRechargeAmountMismatch, + fmt.Sprintf("必须充值%d分才能满足强充要求", forceReq.ForceRechargeAmount)) + } + + // 4. 生成充值订单号 + rechargeNo := s.generateRechargeNo() + + // 5. 创建充值订单 + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: userID, + Updater: userID, + }, + UserID: userID, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: req.Amount, + PaymentMethod: req.PaymentMethod, + Status: constants.RechargeStatusPending, + } + + if err := s.rechargeStore.Create(ctx, recharge); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") + } + + s.logger.Info("创建充值订单成功", + zap.Uint("recharge_id", recharge.ID), + zap.String("recharge_no", rechargeNo), + zap.Int64("amount", req.Amount), + zap.Uint("user_id", userID), + ) + + return s.buildRechargeResponse(recharge), nil +} + +// GetRechargeCheck 充值预检 +// 返回强充要求、金额限制等信息 +func (s *Service) GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*ForceRechargeRequirement, error) { + // 验证资源类型 + if resourceType != "iot_card" && resourceType != "device" { + return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型") + } + + // 验证资源是否存在 + if resourceType == "iot_card" { + if _, err := s.iotCardStore.GetByID(ctx, resourceID); err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + } + } else { + if _, err := s.deviceStore.GetByID(ctx, resourceID); err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "设备不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + } + } + + return s.checkForceRechargeRequirement(ctx, resourceType, resourceID) +} + +// GetByID 根据ID查询充值订单详情 +// 支持数据权限过滤 +func (s *Service) GetByID(ctx context.Context, id uint, userID uint) (*dto.RechargeResponse, error) { + recharge, err := s.rechargeStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeRechargeNotFound, "充值订单不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败") + } + + // 数据权限检查:只能查看自己的充值订单 + if recharge.UserID != userID { + return nil, errors.New(errors.CodeForbidden, "无权查看此充值订单") + } + + return s.buildRechargeResponse(recharge), nil +} + +// List 查询充值订单列表 +// 支持分页、筛选、数据权限 +func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID uint) (*dto.RechargeListResponse, error) { + page := req.Page + pageSize := req.PageSize + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = constants.DefaultPageSize + } + + params := &postgres.ListRechargeParams{ + Page: page, + PageSize: pageSize, + UserID: &userID, // 数据权限:只能查看自己的 + } + + if req.Status != nil { + params.Status = req.Status + } + if req.WalletID != nil { + params.WalletID = req.WalletID + } + if req.StartTime != nil { + params.StartTime = req.StartTime + } + if req.EndTime != nil { + params.EndTime = req.EndTime + } + + recharges, total, err := s.rechargeStore.List(ctx, params) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单列表失败") + } + + var list []*dto.RechargeResponse + for _, r := range recharges { + list = append(list, s.buildRechargeResponse(r)) + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return &dto.RechargeListResponse{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +// HandlePaymentCallback 支付回调处理 +// 支持幂等性检查、事务处理、更新余额、触发佣金 +func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error { + // 1. 查询充值订单 + recharge, err := s.rechargeStore.GetByRechargeNo(ctx, rechargeNo) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败") + } + if recharge == nil { + return errors.New(errors.CodeRechargeNotFound, "充值订单不存在") + } + + // 2. 幂等性检查:已支付则直接返回成功 + if recharge.Status == constants.RechargeStatusPaid || recharge.Status == constants.RechargeStatusCompleted { + s.logger.Info("充值订单已支付,跳过处理", + zap.String("recharge_no", rechargeNo), + zap.Int("status", recharge.Status), + ) + return nil + } + + // 3. 检查订单状态是否允许支付 + if recharge.Status != constants.RechargeStatusPending { + return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付") + } + + // 4. 获取钱包信息 + wallet, err := s.walletStore.GetByID(ctx, recharge.WalletID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") + } + + // 5. 获取钱包对应的资源类型和ID + resourceType := wallet.ResourceType + resourceID := wallet.ResourceID + + // 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金 + now := time.Now() + err = s.db.Transaction(func(tx *gorm.DB) error { + // 6.1 更新充值订单状态(带状态检查,实现乐观锁) + oldStatus := constants.RechargeStatusPending + if err := s.rechargeStore.UpdateStatus(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil { + if err == gorm.ErrRecordNotFound { + // 状态已变更,幂等处理 + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败") + } + + // 6.2 更新支付信息 + if err := s.rechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败") + } + + // 6.3 增加钱包余额(使用乐观锁) + balanceBefore := wallet.Balance + result := tx.Model(&model.Wallet{}). + Where("id = ? AND version = ?", wallet.ID, wallet.Version). + Updates(map[string]any{ + "balance": gorm.Expr("balance + ?", recharge.Amount), + "version": gorm.Expr("version + 1"), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新钱包余额失败") + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeInternalError, "钱包版本冲突,请重试") + } + + // 6.4 创建钱包交易记录 + remark := "钱包充值" + refType := "recharge" + transaction := &model.WalletTransaction{ + WalletID: wallet.ID, + UserID: recharge.UserID, + TransactionType: "recharge", + Amount: recharge.Amount, + BalanceBefore: balanceBefore, + BalanceAfter: balanceBefore + recharge.Amount, + Status: 1, + ReferenceType: &refType, + ReferenceID: &recharge.ID, + Remark: &remark, + Creator: recharge.UserID, + } + if err := tx.Create(transaction).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败") + } + + // 6.5 更新累计充值 + if err := s.updateAccumulatedRechargeInTx(ctx, tx, resourceType, resourceID, recharge.Amount); err != nil { + return err + } + + // 6.6 触发一次性佣金判断 + if err := s.triggerOneTimeCommissionIfNeededInTx(ctx, tx, resourceType, resourceID, recharge.Amount, recharge.UserID); err != nil { + return err + } + + // 6.7 更新充值订单状态为已完成 + if err := tx.Model(&model.RechargeRecord{}). + Where("id = ?", recharge.ID). + Update("status", constants.RechargeStatusCompleted).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单完成状态失败") + } + + return nil + }) + + if err != nil { + return err + } + + s.logger.Info("充值支付回调处理成功", + zap.String("recharge_no", rechargeNo), + zap.Int64("amount", recharge.Amount), + zap.String("resource_type", resourceType), + zap.Uint("resource_id", resourceID), + ) + + return nil +} + +// checkForceRechargeRequirement 检查强充要求 +// 根据资源类型和ID检查是否需要强制充值指定金额 +func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceType string, resourceID uint) (*ForceRechargeRequirement, error) { + result := &ForceRechargeRequirement{ + NeedForceRecharge: false, + MinAmount: constants.RechargeMinAmount, + MaxAmount: constants.RechargeMaxAmount, + Message: "无强充要求,可自由充值", + } + + var seriesAllocationID *uint + var accumulatedRecharge int64 + var firstCommissionPaid bool + + // 1. 查询资源信息 + if resourceType == "iot_card" { + card, err := s.iotCardStore.GetByID(ctx, resourceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + } + seriesAllocationID = card.SeriesAllocationID + accumulatedRecharge = card.AccumulatedRecharge + firstCommissionPaid = card.FirstCommissionPaid + } else if resourceType == "device" { + device, err := s.deviceStore.GetByID(ctx, resourceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "设备不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + } + seriesAllocationID = device.SeriesAllocationID + accumulatedRecharge = device.AccumulatedRecharge + firstCommissionPaid = device.FirstCommissionPaid + } + + result.CurrentAccumulated = accumulatedRecharge + result.FirstCommissionPaid = firstCommissionPaid + + // 2. 如果没有系列分配,无强充要求 + if seriesAllocationID == nil { + return result, nil + } + + // 3. 查询系列分配配置 + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *seriesAllocationID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return result, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败") + } + + // 4. 如果未启用一次性佣金,无强充要求 + if !allocation.EnableOneTimeCommission { + return result, nil + } + + result.Threshold = allocation.OneTimeCommissionThreshold + result.TriggerType = allocation.OneTimeCommissionTrigger + + // 5. 如果一次性佣金已发放,无强充要求 + if firstCommissionPaid { + result.Message = "一次性佣金已发放,无强充要求" + return result, nil + } + + // 6. 根据触发类型判断强充要求 + if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerSingleRecharge { + // 首次充值触发:必须充值阈值金额 + result.NeedForceRecharge = true + result.ForceRechargeAmount = allocation.OneTimeCommissionThreshold + result.Message = fmt.Sprintf("首次充值必须充值%d分", allocation.OneTimeCommissionThreshold) + } else if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + // 累计充值触发:检查是否启用强充 + if allocation.EnableForceRecharge { + result.NeedForceRecharge = true + // 强充金额优先使用配置值,否则使用阈值 + if allocation.ForceRechargeAmount > 0 { + result.ForceRechargeAmount = allocation.ForceRechargeAmount + } else { + result.ForceRechargeAmount = allocation.OneTimeCommissionThreshold + } + result.Message = fmt.Sprintf("每次充值必须充值%d分", result.ForceRechargeAmount) + } else { + result.Message = "累计充值模式,可自由充值" + } + } + + return result, nil +} + +// updateAccumulatedRechargeInTx 更新累计充值(事务内使用) +// 原子操作更新卡或设备的累计充值金额 +func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB, resourceType string, resourceID uint, amount int64) error { + if resourceType == "iot_card" { + result := tx.Model(&model.IotCard{}). + Where("id = ?", resourceID). + Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount)) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡累计充值失败") + } + } else if resourceType == "device" { + result := tx.Model(&model.Device{}). + Where("id = ?", resourceID). + Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount)) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新设备累计充值失败") + } + } + return nil +} + +// triggerOneTimeCommissionIfNeededInTx 触发一次性佣金(事务内使用) +// 检查是否满足一次性佣金触发条件,满足则创建佣金记录并入账 +func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *gorm.DB, resourceType string, resourceID uint, rechargeAmount int64, userID uint) error { + var seriesAllocationID *uint + var accumulatedRecharge int64 + var firstCommissionPaid bool + var shopID *uint + + // 1. 查询资源当前状态(需要从数据库重新查询以获取更新后的累计充值) + if resourceType == "iot_card" { + var card model.IotCard + if err := tx.First(&card, resourceID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + } + seriesAllocationID = card.SeriesAllocationID + accumulatedRecharge = card.AccumulatedRecharge + firstCommissionPaid = card.FirstCommissionPaid + shopID = card.ShopID + } else if resourceType == "device" { + var device model.Device + if err := tx.First(&device, resourceID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + } + seriesAllocationID = device.SeriesAllocationID + accumulatedRecharge = device.AccumulatedRecharge + firstCommissionPaid = device.FirstCommissionPaid + shopID = device.ShopID + } + + // 2. 如果没有系列分配或已发放佣金,跳过 + if seriesAllocationID == nil || firstCommissionPaid { + return nil + } + + // 3. 如果没有归属店铺,无法发放佣金 + if shopID == nil { + s.logger.Warn("资源未归属店铺,无法发放一次性佣金", + zap.String("resource_type", resourceType), + zap.Uint("resource_id", resourceID), + ) + return nil + } + + // 4. 查询系列分配配置 + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *seriesAllocationID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败") + } + + // 5. 如果未启用一次性佣金,跳过 + if !allocation.EnableOneTimeCommission { + return nil + } + + // 6. 根据触发类型判断是否满足条件 + var rechargeAmountToCheck int64 + switch allocation.OneTimeCommissionTrigger { + case model.OneTimeCommissionTriggerSingleRecharge: + rechargeAmountToCheck = rechargeAmount + case model.OneTimeCommissionTriggerAccumulatedRecharge: + rechargeAmountToCheck = accumulatedRecharge + default: + return nil + } + + // 7. 检查是否达到阈值 + if rechargeAmountToCheck < allocation.OneTimeCommissionThreshold { + return nil + } + + // 8. 计算佣金金额 + commissionAmount := s.calculateOneTimeCommission(allocation, rechargeAmount) + if commissionAmount <= 0 { + return nil + } + + // 9. 查询店铺的佣金钱包 + var commissionWallet model.Wallet + if err := tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", *shopID, "commission"). + First(&commissionWallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + s.logger.Warn("店铺佣金钱包不存在,跳过佣金发放", + zap.Uint("shop_id", *shopID), + ) + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺佣金钱包失败") + } + + // 10. 创建佣金记录 + var iotCardID, deviceID *uint + if resourceType == "iot_card" { + iotCardID = &resourceID + } else { + deviceID = &resourceID + } + + commissionRecord := &model.CommissionRecord{ + BaseModel: model.BaseModel{ + Creator: userID, + Updater: userID, + }, + ShopID: *shopID, + IotCardID: iotCardID, + DeviceID: deviceID, + CommissionSource: model.CommissionSourceOneTime, + Amount: commissionAmount, + Status: model.CommissionStatusReleased, + Remark: "钱包充值触发一次性佣金", + } + + if err := tx.Create(commissionRecord).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金记录失败") + } + + // 11. 佣金入账到店铺佣金钱包 + balanceBefore := commissionWallet.Balance + result := tx.Model(&model.Wallet{}). + Where("id = ? AND version = ?", commissionWallet.ID, commissionWallet.Version). + Updates(map[string]any{ + "balance": gorm.Expr("balance + ?", commissionAmount), + "version": gorm.Expr("version + 1"), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新佣金钱包余额失败") + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeInternalError, "佣金钱包版本冲突,请重试") + } + + // 12. 更新佣金记录的入账后余额 + now := time.Now() + if err := tx.Model(commissionRecord).Updates(map[string]any{ + "balance_after": balanceBefore + commissionAmount, + "released_at": now, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败") + } + + // 13. 创建佣金钱包交易记录 + remark := "一次性佣金入账(充值触发)" + refType := "commission" + commissionTransaction := &model.WalletTransaction{ + WalletID: commissionWallet.ID, + UserID: userID, + TransactionType: "commission", + Amount: commissionAmount, + BalanceBefore: balanceBefore, + BalanceAfter: balanceBefore + commissionAmount, + Status: 1, + ReferenceType: &refType, + ReferenceID: &commissionRecord.ID, + Remark: &remark, + Creator: userID, + } + if err := tx.Create(commissionTransaction).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金钱包交易记录失败") + } + + // 14. 标记一次性佣金已发放 + if resourceType == "iot_card" { + if err := tx.Model(&model.IotCard{}).Where("id = ?", resourceID). + Update("first_commission_paid", true).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败") + } + } else { + if err := tx.Model(&model.Device{}).Where("id = ?", resourceID). + Update("first_commission_paid", true).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败") + } + } + + s.logger.Info("一次性佣金发放成功", + zap.String("resource_type", resourceType), + zap.Uint("resource_id", resourceID), + zap.Uint("shop_id", *shopID), + zap.Int64("commission_amount", commissionAmount), + ) + + return nil +} + +// calculateOneTimeCommission 计算一次性佣金金额 +func (s *Service) calculateOneTimeCommission(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 { + if allocation.OneTimeCommissionType == model.OneTimeCommissionTypeFixed { + // 固定佣金 + if allocation.OneTimeCommissionMode == model.CommissionModeFixed { + return allocation.OneTimeCommissionValue + } else if allocation.OneTimeCommissionMode == model.CommissionModePercent { + // 百分比佣金(千分比) + return orderAmount * allocation.OneTimeCommissionValue / 1000 + } + } + // 梯度佣金在此不处理,由 commission_calculation 服务处理 + return 0 +} + +// generateRechargeNo 生成充值订单号 +// 格式: RCH + 14位时间戳 + 6位随机数 +func (s *Service) generateRechargeNo() string { + now := time.Now() + timestamp := now.Format("20060102150405") + randomNum := rand.Intn(1000000) + return fmt.Sprintf("RCH%s%06d", timestamp, randomNum) +} + +// buildRechargeResponse 构建充值订单响应 +func (s *Service) buildRechargeResponse(recharge *model.RechargeRecord) *dto.RechargeResponse { + statusText := "" + switch recharge.Status { + case constants.RechargeStatusPending: + statusText = "待支付" + case constants.RechargeStatusPaid: + statusText = "已支付" + case constants.RechargeStatusCompleted: + statusText = "已完成" + case constants.RechargeStatusClosed: + statusText = "已关闭" + case constants.RechargeStatusRefunded: + statusText = "已退款" + } + + return &dto.RechargeResponse{ + ID: recharge.ID, + RechargeNo: recharge.RechargeNo, + UserID: recharge.UserID, + WalletID: recharge.WalletID, + Amount: recharge.Amount, + PaymentMethod: recharge.PaymentMethod, + PaymentChannel: recharge.PaymentChannel, + PaymentTransactionID: recharge.PaymentTransactionID, + Status: recharge.Status, + StatusText: statusText, + PaidAt: recharge.PaidAt, + CompletedAt: recharge.CompletedAt, + CreatedAt: recharge.CreatedAt, + UpdatedAt: recharge.UpdatedAt, + } +} diff --git a/internal/service/recharge/service_test.go b/internal/service/recharge/service_test.go new file mode 100644 index 0000000..117d5f3 --- /dev/null +++ b/internal/service/recharge/service_test.go @@ -0,0 +1,1488 @@ +package recharge + +import ( + "context" + "fmt" + "testing" + "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/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// setupTestService 创建测试用的 Service 实例 +func setupTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + logger, _ := zap.NewDevelopment() + + // 创建各个 Store + rechargeStore := postgres.NewRechargeStore(tx, rdb) + walletStore := postgres.NewWalletStore(tx, rdb) + walletTransactionStore := postgres.NewWalletTransactionStore(tx, rdb) + iotCardStore := postgres.NewIotCardStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) + commissionRecordStore := postgres.NewCommissionRecordStore(tx, rdb) + + service := New( + tx, + rechargeStore, + walletStore, + walletTransactionStore, + iotCardStore, + deviceStore, + shopSeriesAllocationStore, + commissionRecordStore, + logger, + ) + + return service, tx +} + +// createTestIotCard 创建测试用 IoT 卡 +func createTestIotCard(t *testing.T, tx *gorm.DB, shopID *uint, seriesAllocationID *uint) *model.IotCard { + t.Helper() + timestamp := time.Now().UnixNano() + card := &model.IotCard{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + ICCID: fmt.Sprintf("89860%014d", timestamp%100000000000000), + CardType: "流量卡", + CardCategory: "normal", + CarrierID: 1, + CarrierType: "CMCC", + CarrierName: "中国移动", + Status: 1, + ShopID: shopID, + SeriesAllocationID: seriesAllocationID, + } + require.NoError(t, tx.Create(card).Error) + return card +} + +// createTestDevice 创建测试用设备 +func createTestDevice(t *testing.T, tx *gorm.DB, shopID *uint, seriesAllocationID *uint) *model.Device { + t.Helper() + timestamp := time.Now().UnixNano() + device := &model.Device{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + DeviceNo: fmt.Sprintf("DEV%014d", timestamp%100000000000000), + DeviceName: "测试设备", + DeviceType: "GPS", + Status: 1, + ShopID: shopID, + SeriesAllocationID: seriesAllocationID, + } + require.NoError(t, tx.Create(device).Error) + return device +} + +// createTestWallet 创建测试用钱包 +func createTestWallet(t *testing.T, tx *gorm.DB, resourceType string, resourceID uint, walletType string) *model.Wallet { + t.Helper() + wallet := &model.Wallet{ + ResourceType: resourceType, + ResourceID: resourceID, + WalletType: walletType, + Balance: 0, + Currency: "CNY", + Status: 1, + Version: 0, + } + require.NoError(t, tx.Create(wallet).Error) + return wallet +} + +// createTestShop 创建测试用店铺 +func createTestShop(t *testing.T, tx *gorm.DB) *model.Shop { + t.Helper() + timestamp := time.Now().UnixNano() + shop := &model.Shop{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + ShopName: fmt.Sprintf("测试店铺%d", timestamp%10000), + ShopCode: fmt.Sprintf("SHOP%d", timestamp%1000000), + Level: 1, + ContactName: "测试联系人", + ContactPhone: fmt.Sprintf("138%08d", timestamp%100000000), + Status: 1, + } + require.NoError(t, tx.Create(shop).Error) + return shop +} + +// createTestSeriesAllocation 创建测试用系列分配 +func createTestSeriesAllocation(t *testing.T, tx *gorm.DB, shopID uint, enableOneTime bool, trigger string, threshold int64, enableForceRecharge bool, forceAmount int64) *model.ShopSeriesAllocation { + t.Helper() + allocation := &model.ShopSeriesAllocation{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + ShopID: shopID, + SeriesID: 1, + AllocatorShopID: 0, + BaseCommissionMode: "percent", + BaseCommissionValue: 100, + EnableOneTimeCommission: enableOneTime, + OneTimeCommissionType: "fixed", + OneTimeCommissionTrigger: trigger, + OneTimeCommissionThreshold: threshold, + OneTimeCommissionMode: "fixed", + OneTimeCommissionValue: 5000, // 50元佣金 + EnableForceRecharge: enableForceRecharge, + ForceRechargeAmount: forceAmount, + Status: 1, + } + require.NoError(t, tx.Create(allocation).Error) + return allocation +} + +// TestService_Create 测试创建充值订单 +func TestService_Create(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("成功创建充值订单_iot_card", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建充值订单 + req := &dto.CreateRechargeRequest{ + ResourceType: "iot_card", + ResourceID: card.ID, + Amount: 10000, // 100元 + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int64(10000), resp.Amount) + assert.Equal(t, "wechat", resp.PaymentMethod) + assert.Equal(t, constants.RechargeStatusPending, resp.Status) + assert.True(t, len(resp.RechargeNo) > 0) + assert.Contains(t, resp.RechargeNo, "RCH") + }) + + t.Run("成功创建充值订单_device", func(t *testing.T) { + // 准备测试数据 + device := createTestDevice(t, tx, nil, nil) + createTestWallet(t, tx, "device", device.ID, "main") + + // 创建充值订单 + req := &dto.CreateRechargeRequest{ + ResourceType: "device", + ResourceID: device.ID, + Amount: 5000, // 50元 + PaymentMethod: "alipay", + } + + resp, err := service.Create(ctx, req, 1) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int64(5000), resp.Amount) + assert.Equal(t, "alipay", resp.PaymentMethod) + }) + + t.Run("金额低于最小值", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + createTestWallet(t, tx, "iot_card", card.ID, "main") + + req := &dto.CreateRechargeRequest{ + ResourceType: "iot_card", + ResourceID: card.ID, + Amount: 50, // 0.5元,低于1元 + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "充值金额不能低于1元") + }) + + t.Run("金额超过最大值", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + createTestWallet(t, tx, "iot_card", card.ID, "main") + + req := &dto.CreateRechargeRequest{ + ResourceType: "iot_card", + ResourceID: card.ID, + Amount: 20000000, // 200000元,超过100000元 + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "充值金额不能超过100000元") + }) + + t.Run("无效的资源类型", func(t *testing.T) { + req := &dto.CreateRechargeRequest{ + ResourceType: "invalid", + ResourceID: 1, + Amount: 10000, + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "无效的资源类型") + }) + + t.Run("钱包不存在", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + // 不创建钱包 + + req := &dto.CreateRechargeRequest{ + ResourceType: "iot_card", + ResourceID: card.ID, + Amount: 10000, + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "钱包不存在") + }) + + t.Run("强充金额不匹配_单次充值", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + createTestWallet(t, tx, "iot_card", card.ID, "main") + + req := &dto.CreateRechargeRequest{ + ResourceType: "iot_card", + ResourceID: card.ID, + Amount: 5000, // 50元,但需要100元 + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "强充要求") + }) + + t.Run("强充金额匹配成功_单次充值", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + createTestWallet(t, tx, "iot_card", card.ID, "main") + + req := &dto.CreateRechargeRequest{ + ResourceType: "iot_card", + ResourceID: card.ID, + Amount: 10000, // 100元,符合要求 + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int64(10000), resp.Amount) + }) +} + +// TestService_GetRechargeCheck 测试充值预检 +func TestService_GetRechargeCheck(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("无强充要求_无系列分配", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + + result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + assert.Equal(t, int64(constants.RechargeMinAmount), result.MinAmount) + assert.Equal(t, int64(constants.RechargeMaxAmount), result.MaxAmount) + }) + + t.Run("需要强充_单次充值触发", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.True(t, result.NeedForceRecharge) + assert.Equal(t, int64(10000), result.ForceRechargeAmount) + assert.Equal(t, "single_recharge", result.TriggerType) + }) + + t.Run("需要强充_累计充值启用强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, true, 10000) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.True(t, result.NeedForceRecharge) + assert.Equal(t, int64(10000), result.ForceRechargeAmount) // 使用配置的强充金额 + assert.Equal(t, "accumulated_recharge", result.TriggerType) + }) + + t.Run("无强充要求_累计充值未启用强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + result, err := service.GetRechargeCheck(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + assert.Contains(t, result.Message, "可自由充值") + }) + + t.Run("无效的资源类型", func(t *testing.T) { + result, err := service.GetRechargeCheck(ctx, "invalid", 1) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "无效的资源类型") + }) + + t.Run("资源不存在", func(t *testing.T) { + result, err := service.GetRechargeCheck(ctx, "iot_card", 999999) + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +// TestService_GetByID 测试根据ID查询充值订单 +func TestService_GetByID(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("成功查询", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建充值订单 + timestamp := time.Now().UnixNano() + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d", timestamp), + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 查询 + resp, err := service.GetByID(ctx, recharge.ID, 1) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, recharge.ID, resp.ID) + assert.Equal(t, int64(10000), resp.Amount) + }) + + t.Run("订单不存在", func(t *testing.T) { + resp, err := service.GetByID(ctx, 999999, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "充值订单不存在") + }) + + t.Run("无权查看他人订单", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建用户1的订单 + timestamp := time.Now().UnixNano() + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d", timestamp), + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 用户2尝试查询 + resp, err := service.GetByID(ctx, recharge.ID, 2) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "无权查看") + }) +} + +// TestService_List 测试查询充值订单列表 +func TestService_List(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("查询用户订单列表", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建多个订单 + for i := 0; i < 3; i++ { + timestamp := time.Now().UnixNano() + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 100, + Updater: 100, + }, + UserID: 100, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d%d", timestamp, i), + Amount: int64((i + 1) * 1000), + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + } + + // 查询 + req := &dto.RechargeListRequest{ + Page: 1, + PageSize: 10, + } + + resp, err := service.List(ctx, req, 100) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int64(3), resp.Total) + assert.Len(t, resp.List, 3) + }) + + t.Run("状态筛选", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建不同状态的订单 + for i, status := range []int{constants.RechargeStatusPending, constants.RechargeStatusPaid, constants.RechargeStatusCompleted} { + timestamp := time.Now().UnixNano() + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 101, + Updater: 101, + }, + UserID: 101, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d%d", timestamp, i), + Amount: 10000, + PaymentMethod: "wechat", + Status: status, + } + require.NoError(t, tx.Create(recharge).Error) + } + + // 筛选待支付 + pendingStatus := constants.RechargeStatusPending + req := &dto.RechargeListRequest{ + Page: 1, + PageSize: 10, + Status: &pendingStatus, + } + + resp, err := service.List(ctx, req, 101) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int64(1), resp.Total) + }) +} + +// TestService_HandlePaymentCallback 测试支付回调处理 +func TestService_HandlePaymentCallback(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("成功处理支付回调_无佣金", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建待支付订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证订单状态 + var updatedRecharge model.RechargeRecord + err = tx.First(&updatedRecharge, recharge.ID).Error + require.NoError(t, err) + assert.Equal(t, constants.RechargeStatusCompleted, updatedRecharge.Status) + + // 验证钱包余额 + var updatedWallet model.Wallet + err = tx.First(&updatedWallet, wallet.ID).Error + require.NoError(t, err) + assert.Equal(t, int64(10000), updatedWallet.Balance) + }) + + t.Run("幂等性_已支付订单重复回调", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + wallet.Balance = 10000 + tx.Save(wallet) + + // 创建已完成订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusCompleted, + } + require.NoError(t, tx.Create(recharge).Error) + + // 重复处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) // 应该成功(幂等) + + // 验证钱包余额没有变化 + var updatedWallet model.Wallet + err = tx.First(&updatedWallet, wallet.ID).Error + require.NoError(t, err) + assert.Equal(t, int64(10000), updatedWallet.Balance) // 余额不变 + }) + + t.Run("订单不存在", func(t *testing.T) { + err := service.HandlePaymentCallback(ctx, "RCH_NOT_EXISTS", "wechat", "TX123456") + assert.Error(t, err) + assert.Contains(t, err.Error(), "充值订单不存在") + }) + + t.Run("订单状态不允许支付", func(t *testing.T) { + // 准备测试数据 + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建已关闭订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusClosed, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + assert.Error(t, err) + assert.Contains(t, err.Error(), "订单状态不允许支付") + }) + + t.Run("成功处理支付回调_触发一次性佣金", func(t *testing.T) { + // 准备测试数据 + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + createTestWallet(t, tx, "shop", shop.ID, "commission") // 店铺佣金钱包 + + // 创建待支付订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, // 符合阈值 + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证卡的 FirstCommissionPaid 已更新 + var updatedCard model.IotCard + err = tx.First(&updatedCard, card.ID).Error + require.NoError(t, err) + assert.True(t, updatedCard.FirstCommissionPaid) + + // 验证累计充值已更新 + assert.Equal(t, int64(10000), updatedCard.AccumulatedRecharge) + + // 验证佣金记录已创建 + var commissionRecords []model.CommissionRecord + err = tx.Where("iot_card_id = ?", card.ID).Find(&commissionRecords).Error + require.NoError(t, err) + assert.Len(t, commissionRecords, 1) + assert.Equal(t, int64(5000), commissionRecords[0].Amount) // 50元佣金 + + // 验证店铺佣金钱包余额 + var shopWallet model.Wallet + err = tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", shop.ID, "commission").First(&shopWallet).Error + require.NoError(t, err) + assert.Equal(t, int64(5000), shopWallet.Balance) + }) + + t.Run("累计充值触发佣金", func(t *testing.T) { + // 准备测试数据 + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 15000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + // 设置初始累计充值 + card.AccumulatedRecharge = 10000 + tx.Save(card) + + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + createTestWallet(t, tx, "shop", shop.ID, "commission") + + // 创建待支付订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 5000, // 再充50元,累计150元,达到阈值 + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证累计充值已更新 + var updatedCard model.IotCard + err = tx.First(&updatedCard, card.ID).Error + require.NoError(t, err) + assert.Equal(t, int64(15000), updatedCard.AccumulatedRecharge) + assert.True(t, updatedCard.FirstCommissionPaid) + }) +} + +// TestService_generateRechargeNo 测试生成充值订单号 +func TestService_generateRechargeNo(t *testing.T) { + service, _ := setupTestService(t) + + t.Run("订单号格式正确", func(t *testing.T) { + rechargeNo := service.generateRechargeNo() + assert.True(t, len(rechargeNo) > 0) + assert.Contains(t, rechargeNo, "RCH") + // RCH + 14位时间戳 + 6位随机数 = 23位 + assert.Equal(t, 23, len(rechargeNo)) + }) + + t.Run("订单号唯一性", func(t *testing.T) { + nos := make(map[string]bool) + for i := 0; i < 100; i++ { + no := service.generateRechargeNo() + assert.False(t, nos[no], "订单号应唯一: %s", no) + nos[no] = true + } + }) +} + +// TestService_checkForceRechargeRequirement 测试强充验证逻辑 +func TestService_checkForceRechargeRequirement(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("一次性佣金已发放_无需强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + // 标记佣金已发放 + card.FirstCommissionPaid = true + tx.Save(card) + + result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + assert.True(t, result.FirstCommissionPaid) + }) + + t.Run("一次性佣金未启用_无需强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, false, "", 0, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + }) + + t.Run("设备资源类型", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + device := createTestDevice(t, tx, &shop.ID, &allocation.ID) + + result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) + require.NoError(t, err) + assert.True(t, result.NeedForceRecharge) + assert.Equal(t, int64(10000), result.ForceRechargeAmount) + }) +} + +// TestService_calculateOneTimeCommission 测试计算一次性佣金 +func TestService_calculateOneTimeCommission(t *testing.T) { + service, _ := setupTestService(t) + + t.Run("固定金额佣金", func(t *testing.T) { + allocation := &model.ShopSeriesAllocation{ + OneTimeCommissionType: "fixed", + OneTimeCommissionMode: "fixed", + OneTimeCommissionValue: 5000, // 50元 + } + + amount := service.calculateOneTimeCommission(allocation, 10000) + assert.Equal(t, int64(5000), amount) + }) + + t.Run("百分比佣金", func(t *testing.T) { + allocation := &model.ShopSeriesAllocation{ + OneTimeCommissionType: "fixed", + OneTimeCommissionMode: "percent", + OneTimeCommissionValue: 100, // 10% + } + + amount := service.calculateOneTimeCommission(allocation, 10000) + assert.Equal(t, int64(1000), amount) // 10000 * 100 / 1000 = 1000 + }) + + t.Run("梯度佣金不处理", func(t *testing.T) { + allocation := &model.ShopSeriesAllocation{ + OneTimeCommissionType: "tiered", + OneTimeCommissionMode: "fixed", + OneTimeCommissionValue: 5000, + } + + amount := service.calculateOneTimeCommission(allocation, 10000) + assert.Equal(t, int64(0), amount) // 梯度佣金返回0,由其他服务处理 + }) +} + +// TestService_GetRechargeCheck_Device 测试设备类型的充值预检 +func TestService_GetRechargeCheck_Device(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("设备资源_无强充要求", func(t *testing.T) { + device := createTestDevice(t, tx, nil, nil) + + result, err := service.GetRechargeCheck(ctx, "device", device.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + assert.Equal(t, int64(constants.RechargeMinAmount), result.MinAmount) + assert.Equal(t, int64(constants.RechargeMaxAmount), result.MaxAmount) + }) + + t.Run("设备资源_需要强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 20000, false, 0) + device := createTestDevice(t, tx, &shop.ID, &allocation.ID) + + result, err := service.GetRechargeCheck(ctx, "device", device.ID) + require.NoError(t, err) + assert.True(t, result.NeedForceRecharge) + assert.Equal(t, int64(20000), result.ForceRechargeAmount) + }) + + t.Run("设备资源不存在", func(t *testing.T) { + result, err := service.GetRechargeCheck(ctx, "device", 999999) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "设备不存在") + }) +} + +// TestService_List_MoreFilters 测试更多列表筛选条件 +func TestService_List_MoreFilters(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("钱包ID筛选", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建订单 + timestamp := time.Now().UnixNano() + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 200, + Updater: 200, + }, + UserID: 200, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d", timestamp), + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 按钱包ID筛选 + req := &dto.RechargeListRequest{ + Page: 1, + PageSize: 10, + WalletID: &wallet.ID, + } + + resp, err := service.List(ctx, req, 200) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int64(1), resp.Total) + }) + + t.Run("时间范围筛选", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建订单 + timestamp := time.Now().UnixNano() + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 201, + Updater: 201, + }, + UserID: 201, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d", timestamp), + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 按时间范围筛选 + startTime := time.Now().Add(-1 * time.Hour) + endTime := time.Now().Add(1 * time.Hour) + req := &dto.RechargeListRequest{ + Page: 1, + PageSize: 10, + StartTime: &startTime, + EndTime: &endTime, + } + + resp, err := service.List(ctx, req, 201) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.GreaterOrEqual(t, resp.Total, int64(1)) + }) + + t.Run("默认分页参数", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + // 创建订单 + timestamp := time.Now().UnixNano() + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 202, + Updater: 202, + }, + UserID: 202, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d", timestamp), + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 不传分页参数 + req := &dto.RechargeListRequest{} + + resp, err := service.List(ctx, req, 202) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 1, resp.Page) + assert.Equal(t, constants.DefaultPageSize, resp.PageSize) + }) +} + +// TestService_HandlePaymentCallback_Device 测试设备类型的支付回调 +func TestService_HandlePaymentCallback_Device(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("设备充值_触发佣金", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + device := createTestDevice(t, tx, &shop.ID, &allocation.ID) + wallet := createTestWallet(t, tx, "device", device.ID, "main") + createTestWallet(t, tx, "shop", shop.ID, "commission") + + // 创建待支付订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证设备的 FirstCommissionPaid 已更新 + var updatedDevice model.Device + err = tx.First(&updatedDevice, device.ID).Error + require.NoError(t, err) + assert.True(t, updatedDevice.FirstCommissionPaid) + assert.Equal(t, int64(10000), updatedDevice.AccumulatedRecharge) + }) + + t.Run("设备充值_无店铺归属_跳过佣金", func(t *testing.T) { + // 创建无店铺归属的设备 + allocation := createTestSeriesAllocation(t, tx, 1, true, "single_recharge", 10000, false, 0) + device := createTestDevice(t, tx, nil, &allocation.ID) // 无店铺 + wallet := createTestWallet(t, tx, "device", device.ID, "main") + + // 创建待支付订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 - 应该成功但不发放佣金 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证钱包余额已更新 + var updatedWallet model.Wallet + err = tx.First(&updatedWallet, wallet.ID).Error + require.NoError(t, err) + assert.Equal(t, int64(10000), updatedWallet.Balance) + }) +} + +// TestService_buildRechargeResponse_AllStatus 测试所有状态的响应构建 +func TestService_buildRechargeResponse_AllStatus(t *testing.T) { + service, tx := setupTestService(t) + + testCases := []struct { + status int + statusText string + }{ + {constants.RechargeStatusPending, "待支付"}, + {constants.RechargeStatusPaid, "已支付"}, + {constants.RechargeStatusCompleted, "已完成"}, + {constants.RechargeStatusClosed, "已关闭"}, + {constants.RechargeStatusRefunded, "已退款"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("状态_%s", tc.statusText), func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + now := time.Now() + paymentChannel := "jsapi" + paymentTxID := "TX123" + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: fmt.Sprintf("RCH%d%d", time.Now().UnixNano(), tc.status), + Amount: 10000, + PaymentMethod: "wechat", + PaymentChannel: &paymentChannel, + PaymentTransactionID: &paymentTxID, + Status: tc.status, + PaidAt: &now, + CompletedAt: &now, + } + require.NoError(t, tx.Create(recharge).Error) + + resp := service.buildRechargeResponse(recharge) + assert.Equal(t, tc.status, resp.Status) + assert.Equal(t, tc.statusText, resp.StatusText) + assert.Equal(t, "wechat", resp.PaymentMethod) + assert.NotNil(t, resp.PaymentChannel) + assert.Equal(t, "jsapi", *resp.PaymentChannel) + assert.NotNil(t, resp.PaymentTransactionID) + assert.Equal(t, "TX123", *resp.PaymentTransactionID) + assert.NotNil(t, resp.PaidAt) + assert.NotNil(t, resp.CompletedAt) + }) + } +} + +// TestService_Create_Device 测试设备类型的创建 +func TestService_Create_Device(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("设备钱包不存在", func(t *testing.T) { + device := createTestDevice(t, tx, nil, nil) + + req := &dto.CreateRechargeRequest{ + ResourceType: "device", + ResourceID: device.ID, + Amount: 10000, + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "钱包不存在") + }) + + t.Run("设备强充金额不匹配", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 20000, false, 0) + device := createTestDevice(t, tx, &shop.ID, &allocation.ID) + createTestWallet(t, tx, "device", device.ID, "main") + + req := &dto.CreateRechargeRequest{ + ResourceType: "device", + ResourceID: device.ID, + Amount: 10000, + PaymentMethod: "wechat", + } + + resp, err := service.Create(ctx, req, 1) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "强充要求") + }) + + t.Run("设备成功创建充值订单", func(t *testing.T) { + device := createTestDevice(t, tx, nil, nil) + createTestWallet(t, tx, "device", device.ID, "main") + + req := &dto.CreateRechargeRequest{ + ResourceType: "device", + ResourceID: device.ID, + Amount: 10000, + PaymentMethod: "alipay", + } + + resp, err := service.Create(ctx, req, 1) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int64(10000), resp.Amount) + assert.Equal(t, "alipay", resp.PaymentMethod) + }) +} + +// TestService_checkForceRechargeRequirement_MoreCases 测试更多强充验证场景 +func TestService_checkForceRechargeRequirement_MoreCases(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("无系列分配_无需强充", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + + result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + }) + + t.Run("系列分配不存在_无需强充", func(t *testing.T) { + nonExistentID := uint(999999) + card := createTestIotCard(t, tx, nil, &nonExistentID) + + result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + }) + + t.Run("累计充值触发_启用强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, true, 15000) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.True(t, result.NeedForceRecharge) + assert.Equal(t, int64(15000), result.ForceRechargeAmount) + assert.Equal(t, "accumulated_recharge", result.TriggerType) + }) + + t.Run("未知触发类型_无需强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := &model.ShopSeriesAllocation{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + ShopID: shop.ID, + SeriesID: 1, + AllocatorShopID: 0, + BaseCommissionMode: "percent", + BaseCommissionValue: 100, + EnableOneTimeCommission: true, + OneTimeCommissionType: "fixed", + OneTimeCommissionTrigger: "unknown_trigger", + OneTimeCommissionThreshold: 10000, + OneTimeCommissionMode: "fixed", + OneTimeCommissionValue: 5000, + EnableForceRecharge: false, + ForceRechargeAmount: 0, + Status: 1, + } + require.NoError(t, tx.Create(allocation).Error) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + }) + + t.Run("设备_累计充值触发_启用强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "accumulated_recharge", 50000, true, 15000) + device := createTestDevice(t, tx, &shop.ID, &allocation.ID) + + result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) + require.NoError(t, err) + assert.True(t, result.NeedForceRecharge) + assert.Equal(t, int64(15000), result.ForceRechargeAmount) + }) + + t.Run("设备_佣金已发放_无需强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + device := createTestDevice(t, tx, &shop.ID, &allocation.ID) + device.FirstCommissionPaid = true + tx.Save(device) + + result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + assert.True(t, result.FirstCommissionPaid) + }) + + t.Run("设备_系列分配不存在_无需强充", func(t *testing.T) { + nonExistentID := uint(999999) + device := createTestDevice(t, tx, nil, &nonExistentID) + + result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + }) + + t.Run("设备_无系列分配_无需强充", func(t *testing.T) { + device := createTestDevice(t, tx, nil, nil) + + result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + }) + + t.Run("设备_一次性佣金未启用_无需强充", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, false, "", 0, false, 0) + device := createTestDevice(t, tx, &shop.ID, &allocation.ID) + + result, err := service.checkForceRechargeRequirement(ctx, "device", device.ID) + require.NoError(t, err) + assert.False(t, result.NeedForceRecharge) + }) + + t.Run("累计充值触发_启用强充_无配置金额_使用阈值", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := &model.ShopSeriesAllocation{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + ShopID: shop.ID, + SeriesID: 1, + AllocatorShopID: 0, + BaseCommissionMode: "percent", + BaseCommissionValue: 100, + EnableOneTimeCommission: true, + OneTimeCommissionType: "fixed", + OneTimeCommissionTrigger: "accumulated_recharge", + OneTimeCommissionThreshold: 50000, + OneTimeCommissionMode: "fixed", + OneTimeCommissionValue: 5000, + EnableForceRecharge: true, + ForceRechargeAmount: 0, + Status: 1, + } + require.NoError(t, tx.Create(allocation).Error) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + + result, err := service.checkForceRechargeRequirement(ctx, "iot_card", card.ID) + require.NoError(t, err) + assert.True(t, result.NeedForceRecharge) + assert.Equal(t, int64(50000), result.ForceRechargeAmount) + }) +} + +// TestService_HandlePaymentCallback_MoreCases 测试更多支付回调场景 +func TestService_HandlePaymentCallback_MoreCases(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("已支付状态_幂等返回成功", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPaid, + } + require.NoError(t, tx.Create(recharge).Error) + + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + }) + + t.Run("已退款状态_不允许支付", func(t *testing.T) { + card := createTestIotCard(t, tx, nil, nil) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusRefunded, + } + require.NoError(t, tx.Create(recharge).Error) + + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + assert.Error(t, err) + assert.Contains(t, err.Error(), "订单状态不允许支付") + }) +} + +// TestService_triggerOneTimeCommission_EdgeCases 测试一次性佣金触发的边界情况 +func TestService_triggerOneTimeCommission_EdgeCases(t *testing.T) { + service, tx := setupTestService(t) + ctx := context.Background() + + t.Run("佣金金额为0_不发放", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := &model.ShopSeriesAllocation{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + ShopID: shop.ID, + SeriesID: 1, + AllocatorShopID: 0, + BaseCommissionMode: "percent", + BaseCommissionValue: 100, + EnableOneTimeCommission: true, + OneTimeCommissionType: "fixed", + OneTimeCommissionTrigger: "single_recharge", + OneTimeCommissionThreshold: 10000, + OneTimeCommissionMode: "fixed", + OneTimeCommissionValue: 0, // 佣金金额为0 + EnableForceRecharge: false, + ForceRechargeAmount: 0, + Status: 1, + } + require.NoError(t, tx.Create(allocation).Error) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + createTestWallet(t, tx, "shop", shop.ID, "commission") + + // 创建待支付订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证没有创建佣金记录 + var commissionRecords []model.CommissionRecord + err = tx.Where("iot_card_id = ?", card.ID).Find(&commissionRecords).Error + require.NoError(t, err) + assert.Len(t, commissionRecords, 0) + }) + + t.Run("未达到阈值_不触发佣金", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 20000, false, 0) // 阈值200元 + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + createTestWallet(t, tx, "shop", shop.ID, "commission") + + // 创建待支付订单(金额低于阈值) + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, // 100元,低于200元阈值 + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证没有创建佣金记录 + var commissionRecords []model.CommissionRecord + err = tx.Where("iot_card_id = ?", card.ID).Find(&commissionRecords).Error + require.NoError(t, err) + assert.Len(t, commissionRecords, 0) + + // 验证 FirstCommissionPaid 未更新 + var updatedCard model.IotCard + err = tx.First(&updatedCard, card.ID).Error + require.NoError(t, err) + assert.False(t, updatedCard.FirstCommissionPaid) + }) + + t.Run("店铺佣金钱包不存在_跳过佣金", func(t *testing.T) { + shop := createTestShop(t, tx) + allocation := createTestSeriesAllocation(t, tx, shop.ID, true, "single_recharge", 10000, false, 0) + card := createTestIotCard(t, tx, &shop.ID, &allocation.ID) + wallet := createTestWallet(t, tx, "iot_card", card.ID, "main") + // 不创建店铺佣金钱包 + + // 创建待支付订单 + rechargeNo := fmt.Sprintf("RCH%d", time.Now().UnixNano()) + recharge := &model.RechargeRecord{ + BaseModel: model.BaseModel{ + Creator: 1, + Updater: 1, + }, + UserID: 1, + WalletID: wallet.ID, + RechargeNo: rechargeNo, + Amount: 10000, + PaymentMethod: "wechat", + Status: constants.RechargeStatusPending, + } + require.NoError(t, tx.Create(recharge).Error) + + // 处理回调 - 应该成功但不发放佣金 + err := service.HandlePaymentCallback(ctx, rechargeNo, "wechat", "TX123456") + require.NoError(t, err) + + // 验证钱包余额已更新 + var updatedWallet model.Wallet + err = tx.First(&updatedWallet, wallet.ID).Error + require.NoError(t, err) + assert.Equal(t, int64(10000), updatedWallet.Balance) + }) +}