// Package refund 提供退款申请的业务逻辑服务 // 包含退款申请的创建、审批、拒绝、退回、重新提交等完整生命周期管理 // 审批通过后异步执行佣金回扣和退款后资产处理 package refund import ( "context" "fmt" "math/rand" "time" "go.uber.org/zap" "gorm.io/gorm" "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" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package" ) // Service 退款业务服务 // 负责退款申请的 CRUD、审批流程、佣金回扣和退款后资产处理 type Service struct { db *gorm.DB refundStore *postgres.RefundStore orderStore *postgres.OrderStore commissionRecordStore *postgres.CommissionRecordStore agentWalletStore *postgres.AgentWalletStore agentWalletTransactionStore *postgres.AgentWalletTransactionStore stopResumeService *iotCardSvc.StopResumeService deviceService *deviceSvc.Service packageActivationService *packageSvc.ActivationService iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore assetWalletStore *postgres.AssetWalletStore logger *zap.Logger } // New 创建退款业务服务实例 func New( db *gorm.DB, refundStore *postgres.RefundStore, orderStore *postgres.OrderStore, commissionRecordStore *postgres.CommissionRecordStore, agentWalletStore *postgres.AgentWalletStore, agentWalletTransactionStore *postgres.AgentWalletTransactionStore, stopResumeService *iotCardSvc.StopResumeService, deviceService *deviceSvc.Service, packageActivationService *packageSvc.ActivationService, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, assetWalletStore *postgres.AssetWalletStore, logger *zap.Logger, ) *Service { return &Service{ db: db, refundStore: refundStore, orderStore: orderStore, commissionRecordStore: commissionRecordStore, agentWalletStore: agentWalletStore, agentWalletTransactionStore: agentWalletTransactionStore, stopResumeService: stopResumeService, deviceService: deviceService, packageActivationService: packageActivationService, iotCardStore: iotCardStore, deviceStore: deviceStore, assetWalletStore: assetWalletStore, logger: logger, } } // Create 创建退款申请 // 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录 func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") } // 校验订单存在且已支付 order, err := s.orderStore.GetByID(ctx, req.OrderID) if err != nil { return nil, errors.New(errors.CodeNotFound, "订单不存在") } if order.PaymentStatus != model.PaymentStatusPaid { if order.PaymentStatus == model.PaymentStatusRefunded { return nil, errors.New(errors.CodeInvalidStatus, "订单已退款,无法重复申请退款") } return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款") } if err := validateRequestedRefundAmountByOrder(req.RequestedRefundAmount, order); err != nil { return nil, err } // 检查是否存在活跃退款申请(待审批、已通过、已退回状态) existing, err := s.refundStore.FindActiveByOrderID(ctx, req.OrderID) if err == nil && existing != nil { return nil, errors.New(errors.CodeConflict, "该订单已存在退款申请") } // 从订单获取 shop_id:优先使用 SellerShopID,代理商买家使用 BuyerID var shopID *uint if order.SellerShopID != nil { shopID = order.SellerShopID } else if order.BuyerType == model.BuyerTypeAgent { shopID = &order.BuyerID } refund := &model.RefundRequest{ RefundNo: generateRefundNo(), OrderID: req.OrderID, OrderNo: order.OrderNo, OrderType: order.OrderType, AssetIdentifier: order.AssetIdentifier, IotCardID: order.IotCardID, DeviceID: order.DeviceID, PackageUsageID: req.PackageUsageID, ShopID: shopID, ActualReceivedAmount: req.ActualReceivedAmount, RequestedRefundAmount: req.RequestedRefundAmount, RefundReason: req.RefundReason, Status: model.RefundStatusPending, } refund.Creator = userID refund.Updater = userID if err := s.refundStore.Create(ctx, refund); err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "创建退款申请失败") } return buildRefundResponse(refund), nil } // List 分页查询退款申请列表 func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.RefundListResponse, error) { opts := &store.QueryOptions{ Page: req.Page, PageSize: req.PageSize, OrderBy: "tb_refund_request.created_at DESC", } if opts.Page == 0 { opts.Page = 1 } if opts.PageSize == 0 { opts.PageSize = constants.DefaultPageSize } filters := &postgres.RefundListFilters{ Status: req.Status, OrderID: req.OrderID, ShopID: req.ShopID, } requests, total, err := s.refundStore.List(ctx, opts, filters) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询退款申请列表失败") } items := make([]dto.RefundResponse, 0, len(requests)) for _, r := range requests { items = append(items, *buildRefundResponse(r)) } return &dto.RefundListResponse{ Items: items, Total: total, Page: opts.Page, Size: opts.PageSize, }, nil } // GetByID 根据 ID 查询退款申请详情 func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) { refund, err := s.refundStore.GetByID(ctx, id) if err != nil { return nil, errors.New(errors.CodeNotFound, "退款申请不存在") } return buildRefundResponse(refund), nil } // Approve 审批通过退款申请 // 条件更新 WHERE status=1,设置审批信息 // 事务提交成功后异步执行佣金回扣和退款后资产处理 func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") } if err := ensureRefundProcessor(ctx); err != nil { return err } refund, err := s.refundStore.GetByID(ctx, id) if err != nil { return errors.New(errors.CodeNotFound, "退款申请不存在") } if refund.Status != model.RefundStatusPending { return errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过") } now := time.Now() approvedAmount := refund.RequestedRefundAmount if req.ApprovedRefundAmount != nil { approvedAmount = *req.ApprovedRefundAmount } // 事务内同步更新退款状态和订单支付状态,确保审批成功后订单立即变为已退款 if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.RefundRequest{}). Where("id = ? AND status = ?", id, model.RefundStatusPending). Updates(map[string]any{ "status": model.RefundStatusApproved, "processor_id": userID, "processed_at": now, "approved_refund_amount": approvedAmount, "remark": req.Remark, "updater": userID, "updated_at": now, }) if result.Error != nil { return errors.Wrap(errors.CodeInternalError, result.Error, "审批退款申请失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") } orderResult := tx.Model(&model.Order{}). Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid). Updates(map[string]any{ "payment_status": model.PaymentStatusRefunded, "updated_at": now, }) if orderResult.Error != nil { return errors.Wrap(errors.CodeInternalError, orderResult.Error, "更新订单退款状态失败") } if orderResult.RowsAffected == 0 { return errors.New(errors.CodeInvalidStatus, "订单状态已变更,无法执行退款审批") } return nil }); err != nil { return err } // 事务提交成功后,异步执行佣金回扣和退款后资产处理(失败不影响审批结果) go func() { asyncCtx := context.Background() s.deductAllCommission(asyncCtx, id) }() go func() { asyncCtx := context.Background() s.handleRefundAssetProcessing(asyncCtx, id) }() return nil } // Reject 审批拒绝退款申请 // 条件更新 WHERE status=1 func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequest) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") } if err := ensureRefundProcessor(ctx); err != nil { return err } now := time.Now() result := s.db.WithContext(ctx). Model(&model.RefundRequest{}). Where("id = ? AND status = ?", id, model.RefundStatusPending). Updates(map[string]any{ "status": model.RefundStatusRejected, "processor_id": userID, "processed_at": now, "reject_reason": req.RejectReason, "updater": userID, "updated_at": now, }) if result.Error != nil { return errors.Wrap(errors.CodeInternalError, result.Error, "拒绝退款申请失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") } return nil } // Return 退回退款申请 // 条件更新 WHERE status=1,退回后可重新提交 func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequest) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") } if err := ensureRefundProcessor(ctx); err != nil { return err } now := time.Now() result := s.db.WithContext(ctx). Model(&model.RefundRequest{}). Where("id = ? AND status = ?", id, model.RefundStatusPending). Updates(map[string]any{ "status": model.RefundStatusReturned, "processor_id": userID, "processed_at": now, "remark": req.Remark, "updater": userID, "updated_at": now, }) if result.Error != nil { return errors.Wrap(errors.CodeInternalError, result.Error, "退回退款申请失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") } return nil } // ensureRefundProcessor 确保只有平台侧账号可以处理审批类动作。 func ensureRefundProcessor(ctx context.Context) error { userType := middleware.GetUserTypeFromContext(ctx) if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform { return nil } return errors.New(errors.CodeForbidden, "无权限处理退款申请") } // Resubmit 重新提交退款申请 // 条件更新 WHERE status=4(已退回),修改部分字段后重新进入待审批状态 func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefundRequest) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") } refund, err := s.refundStore.GetByID(ctx, id) if err != nil { return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") } if refund.Status != model.RefundStatusReturned { return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") } requestedRefundAmount := refund.RequestedRefundAmount if req.RequestedRefundAmount != nil { requestedRefundAmount = *req.RequestedRefundAmount } order, err := s.orderStore.GetByID(ctx, refund.OrderID) if err != nil { return errors.New(errors.CodeNotFound, "订单不存在") } if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil { return err } now := time.Now() updates := map[string]any{ "status": model.RefundStatusPending, "updater": userID, "updated_at": now, } if req.ActualReceivedAmount != nil { updates["actual_received_amount"] = *req.ActualReceivedAmount } if req.RequestedRefundAmount != nil { updates["requested_refund_amount"] = *req.RequestedRefundAmount } if req.RefundReason != nil { updates["refund_reason"] = *req.RefundReason } result := s.db.WithContext(ctx). Model(&model.RefundRequest{}). Where("id = ? AND status = ?", id, model.RefundStatusReturned). Updates(updates) if result.Error != nil { return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") } return nil } // deductAllCommission 异步回扣该订单所有已入账佣金 // 查询退款单关联订单的所有已入账佣金记录,逐条从代理佣金钱包扣减 // 失败仅记录日志,不影响审批结果 func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { logger := s.logger // 查询退款单 var refund model.RefundRequest if err := s.db.Where("id = ?", refundID).First(&refund).Error; err != nil { logger.Error("佣金回扣:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err)) return } // 查询该订单所有已入账佣金记录 var commissions []model.CommissionRecord if err := s.db.Where("order_id = ? AND status = ?", refund.OrderID, constants.CommissionStatusReleased).Find(&commissions).Error; err != nil { logger.Error("佣金回扣:查询佣金记录失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err)) return } if len(commissions) == 0 { logger.Info("佣金回扣:无已入账佣金记录", zap.Uint("refund_id", refundID)) // 无佣金需要回扣,直接标记完成 s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true) return } // 对每条佣金记录执行扣减 for _, commission := range commissions { if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil { logger.Error("佣金回扣:单条佣金扣减失败", zap.Uint("refund_id", refundID), zap.Uint("commission_id", commission.ID), zap.Uint("shop_id", commission.ShopID), zap.Int64("amount", commission.Amount), zap.Error(err), ) // 继续处理下一条,不中断 } } // 全部完成后标记退款单佣金已回扣 if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true).Error; err != nil { logger.Error("佣金回扣:更新回扣标记失败", zap.Uint("refund_id", refundID), zap.Error(err)) } } // deductSingleCommission 扣减单条佣金记录对应的代理钱包余额 // 使用乐观锁扣减(允许余额为负数),并创建交易流水 func (s *Service) deductSingleCommission(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord) error { // 获取对应代理的佣金钱包 wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, commission.ShopID) if err != nil { return fmt.Errorf("获取佣金钱包失败: shop_id=%d, %w", commission.ShopID, err) } // 乐观锁扣减余额(允许负数) result := s.db.Model(&model.AgentWallet{}). Where("id = ? AND version = ?", wallet.ID, wallet.Version). Updates(map[string]any{ "balance": gorm.Expr("balance - ?", commission.Amount), "version": gorm.Expr("version + 1"), }) if result.Error != nil { return fmt.Errorf("扣减钱包余额失败: %w", result.Error) } if result.RowsAffected == 0 { return fmt.Errorf("乐观锁冲突: wallet_id=%d, version=%d", wallet.ID, wallet.Version) } // 创建交易流水 refType := constants.ReferenceTypeRefund refID := refund.ID transaction := &model.AgentWalletTransaction{ AgentWalletID: wallet.ID, ShopID: commission.ShopID, UserID: refund.Creator, TransactionType: constants.AgentTransactionTypeCommissionDeduct, Amount: -commission.Amount, BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - commission.Amount, Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &refID, Creator: refund.Creator, ShopIDTag: commission.ShopID, } if err := s.agentWalletTransactionStore.CreateWithTx(ctx, s.db, transaction); err != nil { return fmt.Errorf("创建交易流水失败: %w", err) } return nil } // handleRefundAssetProcessing 异步处理退款后的资产状态 // 包括:退款套餐精准失效、尝试接续待生效主套餐、必要时停机 // 失败仅记录日志,不影响审批结果 func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint) { logger := s.logger // 查询退款单 var refund model.RefundRequest if err := s.db.Where("id = ?", refundID).First(&refund).Error; err != nil { logger.Error("退款资产处理:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err)) return } // 查询关联订单 var order model.Order if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil { logger.Error("退款资产处理:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err)) return } // 确定资产类型和 ID var assetType string var assetID uint switch order.OrderType { case model.OrderTypeSingleCard: if order.IotCardID == nil { logger.Error("退款资产处理:单卡订单缺少 iot_card_id", zap.Uint("order_id", order.ID)) return } assetType = "iot_card" assetID = *order.IotCardID case model.OrderTypeDevice: if order.DeviceID == nil { logger.Error("退款资产处理:设备订单缺少 device_id", zap.Uint("order_id", order.ID)) return } assetType = "device" assetID = *order.DeviceID default: logger.Error("退款资产处理:未知订单类型", zap.String("order_type", order.OrderType)) return } // 1. 按退款单精准失效套餐(仅处理本次退款订单关联套餐) if s.packageActivationService == nil { logger.Error("退款资产处理:套餐激活服务未注入", zap.Uint("refund_id", refund.ID), zap.Uint("order_id", order.ID)) return } if err := s.packageActivationService.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.ID, refund.RefundNo, refund.PackageUsageID); err != nil { fields := []zap.Field{ zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("order_id", order.ID), zap.Uint("refund_id", refund.ID), zap.String("refund_no", refund.RefundNo), zap.Error(err), } if refund.PackageUsageID != nil { fields = append(fields, zap.Uint("package_usage_id", *refund.PackageUsageID)) } logger.Error("退款资产处理:退款套餐精准失效失败", fields...) return } // 2. 尝试按购买顺序接续待生效主套餐 if _, err := s.packageActivationService.ActivateNextPendingMainPackage(ctx, assetType, assetID); err != nil { logger.Error("退款资产处理:接续激活待生效套餐失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("refund_id", refund.ID), zap.Error(err)) return } hasActiveMain, err := s.packageActivationService.HasActiveMainPackage(ctx, assetType, assetID) if err != nil { logger.Error("退款资产处理:查询生效主套餐失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("refund_id", refund.ID), zap.Error(err)) return } if !hasActiveMain { // 3. 无可用主套餐时才停机;退款不再重置世代或重建钱包。 s.stopAsset(ctx, assetType, assetID) } // 4. 标记退款后资产处理已完成 if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("asset_reset", true).Error; err != nil { logger.Error("退款资产处理:更新处理标记失败", zap.Uint("refund_id", refundID), zap.Error(err)) } } // stopAsset 根据资产类型执行停机操作 func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) { logger := s.logger switch assetType { case "iot_card": // 单卡停机:需要先查卡获取 iccid card, err := s.iotCardStore.GetByID(ctx, assetID) if err != nil { logger.Error("退款资产处理:查询卡信息失败", zap.Uint("card_id", assetID), zap.Error(err)) return } if s.stopResumeService != nil { if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil { logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err)) } } case "device": // 设备停机 if s.deviceService != nil { if _, err := s.deviceService.StopDevice(ctx, assetID); err != nil { logger.Error("退款资产处理:设备停机失败", zap.Uint("device_id", assetID), zap.Error(err)) } } } } // generateRefundNo 生成退款单号 // 格式:RF + 日期时间 + 6位随机数 func generateRefundNo() string { now := time.Now() randomNum := rand.Intn(1000000) return fmt.Sprintf("RF%s%06d", now.Format("20060102150405"), randomNum) } // validateRequestedRefundAmountByOrder 校验申请退款金额不得超过订单实收金额 func validateRequestedRefundAmountByOrder(requestedRefundAmount int64, order *model.Order) error { if order.ActualPaidAmount == nil { return errors.New(errors.CodeInternalError, "订单实收金额不能为空") } if requestedRefundAmount > *order.ActualPaidAmount { return errors.New(errors.CodeInvalidParam, "申请退款金额不能大于订单实收金额") } return nil } // buildRefundResponse 将退款 Model 转换为 DTO 响应 func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { assetType := "" if r.OrderType == model.OrderTypeSingleCard { assetType = "card" } else if r.OrderType == model.OrderTypeDevice { assetType = "device" } resp := &dto.RefundResponse{ ID: r.ID, RefundNo: r.RefundNo, OrderID: r.OrderID, OrderNo: r.OrderNo, AssetIdentifier: r.AssetIdentifier, AssetType: assetType, IotCardID: r.IotCardID, DeviceID: r.DeviceID, PackageUsageID: r.PackageUsageID, ShopID: r.ShopID, ShopName: r.ShopName, ActualReceivedAmount: r.ActualReceivedAmount, RequestedRefundAmount: r.RequestedRefundAmount, ApprovedRefundAmount: r.ApprovedRefundAmount, RefundReason: r.RefundReason, Status: r.Status, StatusName: constants.GetRefundStatusName(r.Status), ProcessorID: r.ProcessorID, RejectReason: r.RejectReason, Remark: r.Remark, CommissionDeducted: r.CommissionDeducted, AssetReset: r.AssetReset, Creator: r.Creator, Updater: r.Updater, CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"), } if r.ProcessedAt != nil { resp.ProcessedAt = r.ProcessedAt.Format("2006-01-02 15:04:05") } return resp }