// 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 { return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款") } // 检查是否存在活跃退款申请(待审批、已通过、已退回状态) 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, 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, "未授权访问") } 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 } // 条件更新确保幂等性 result := s.db.WithContext(ctx). 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, "退款申请状态已变更,请刷新后重试") } // 事务提交成功后,异步执行佣金回扣和资产重置(失败不影响审批结果) go func() { asyncCtx := context.Background() s.deductAllCommission(asyncCtx, id) }() go func() { asyncCtx := context.Background() s.handleAssetReset(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, "未授权访问") } 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, "未授权访问") } 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 } // 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, "未授权访问") } 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 } // handleAssetReset 异步处理资产重置 // 包括:退款套餐精准失效、停机、世代重置(generation+1)、钱包重建、订单状态更新 // 失败仅记录日志,不影响审批结果 func (s *Service) handleAssetReset(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 { if err := s.packageActivationService.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.PackageUsageID); err != nil { fields := []zap.Field{ zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("order_id", order.ID), zap.Error(err), } if refund.PackageUsageID != nil { fields = append(fields, zap.Uint("package_usage_id", *refund.PackageUsageID)) } logger.Error("资产重置:退款套餐精准失效失败", fields...) } } // 2. 停机 s.stopAsset(ctx, assetType, assetID) // 3. 世代重置(参照 exchange/service.go) if err := s.resetAssetGeneration(ctx, assetType, assetID); err != nil { logger.Error("资产重置:世代重置失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err)) return } // 4. 更新订单支付状态为已退款 if err := s.db.Model(&model.Order{}).Where("id = ?", order.ID).Update("payment_status", model.PaymentStatusRefunded).Error; err != nil { logger.Error("资产重置:更新订单状态失败", zap.Uint("order_id", order.ID), zap.Error(err)) } // 5. 标记退款单资产已重置 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)) } } } } // resetAssetGeneration 执行资产世代重置 // 参照 exchange/service.go 实现: // 1. generation+1, asset_status=1, 清零累计充值和佣金标记 // 2. 清理个人客户绑定 // 3. 删除旧钱包、创建新空钱包 func (s *Service) resetAssetGeneration(ctx context.Context, assetType string, assetID uint) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() if assetType == "iot_card" { var card model.IotCard if err := tx.Where("id = ?", assetID).First(&card).Error; err != nil { return fmt.Errorf("查询卡信息失败: %w", err) } // 世代+1, 重置状态和累计数据 if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ "generation": card.Generation + 1, "asset_status": 1, "accumulated_recharge": 0, "first_commission_paid": false, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updated_at": now, }).Error; err != nil { return fmt.Errorf("重置卡世代失败: %w", err) } // 清理个人客户绑定(按 virtual_no) if card.VirtualNo != "" { if err := tx.Where("virtual_no = ?", card.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil { return fmt.Errorf("清理个人客户绑定失败: %w", err) } } // 删除旧钱包、创建新空钱包 if err := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil { return fmt.Errorf("清理旧钱包失败: %w", err) } shopTag := uint(0) if card.ShopID != nil { shopTag = *card.ShopID } if err := tx.Create(&model.AssetWallet{ ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag, }).Error; err != nil { return fmt.Errorf("创建新钱包失败: %w", err) } return nil } // 设备类型 var device model.Device if err := tx.Where("id = ?", assetID).First(&device).Error; err != nil { return fmt.Errorf("查询设备失败: %w", err) } // 世代+1, 重置状态和累计数据 if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{ "generation": device.Generation + 1, "asset_status": 1, "accumulated_recharge": 0, "first_commission_paid": false, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updated_at": now, }).Error; err != nil { return fmt.Errorf("重置设备世代失败: %w", err) } // 清理个人客户绑定(按 virtual_no) if device.VirtualNo != "" { if err := tx.Where("virtual_no = ?", device.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil { return fmt.Errorf("清理个人客户绑定失败: %w", err) } } // 删除旧钱包、创建新空钱包 if err := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil { return fmt.Errorf("清理旧钱包失败: %w", err) } shopTag := uint(0) if device.ShopID != nil { shopTag = *device.ShopID } if err := tx.Create(&model.AssetWallet{ ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag, }).Error; err != nil { return fmt.Errorf("创建新钱包失败: %w", err) } return nil }) } // generateRefundNo 生成退款单号 // 格式:RF + 日期时间 + 6位随机数 func generateRefundNo() string { now := time.Now() randomNum := rand.Intn(1000000) return fmt.Sprintf("RF%s%06d", now.Format("20060102150405"), randomNum) } // buildRefundResponse 将退款 Model 转换为 DTO 响应 func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { resp := &dto.RefundResponse{ ID: r.ID, RefundNo: r.RefundNo, OrderID: r.OrderID, OrderNo: r.OrderNo, 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 }