// Package refund 提供退款申请的业务逻辑服务 // 包含退款申请的创建、审批、拒绝、退回、重新提交等完整生命周期管理 // 审批通过后异步执行佣金回扣和退款后资产处理 package refund import ( "context" "fmt" "math/rand" "strconv" "strings" "time" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "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/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/config" "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 agentWalletRefundService *walletapp.RefundService refundApprovalCreation *refundapprovalapp.CreationService notificationOutbox *outbox.Repository auditWriter *audit.Writer logger *zap.Logger paymentMerchantRuntime *merchantpayment.RuntimeLoader refundOffset *employeecollectionapp.RefundOffsetService channelRefund *refundchannelapp.Service } // SetChannelRefundService 注入渠道原路退款用例。 func (s *Service) SetChannelRefundService(service *refundchannelapp.Service) { s.channelRefund = service } // SetEmployeeCollectionRefundOffset 注入员工代收款账单退款冲销用例。 func (s *Service) SetEmployeeCollectionRefundOffset(offset *employeecollectionapp.RefundOffsetService) { s.refundOffset = offset } // 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, } } // SetAgentWalletRefundService 注入代理主钱包统一退款回充用例。 func (s *Service) SetAgentWalletRefundService(service *walletapp.RefundService) { s.agentWalletRefundService = service } // SetRefundApprovalCreationService 注入退款企微审批申请用例。 func (s *Service) SetRefundApprovalCreationService(service *refundapprovalapp.CreationService) { s.refundApprovalCreation = service } // SetNotificationOutbox 注入退款完成后的可靠店铺通知 Outbox。 func (s *Service) SetNotificationOutbox(repository *outbox.Repository) { s.notificationOutbox = repository } // SetLifecycleAudit 注入退款完整业务链统一审计 Writer。 func (s *Service) SetLifecycleAudit(writer *audit.Writer) { s.auditWriter = writer } // 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, "仅已支付订单可申请退款") } decision, err := s.decideRefundMethods(ctx, order) if err != nil { return nil, err } if err := validateRefundMethod(decision, req.Method); err != nil { return nil, err } if err := validateFrozenRefundAmount(req.RequestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil { return nil, err } refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey) if err != nil { return nil, err } if err := validateCustomerAccountMaterial(req.Method, req.CustomerAccountInfo, refundVoucherKey); err != nil { return nil, err } // 从订单获取 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: decision.FrozenActualReceivedAmount, FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount, RequestedRefundAmount: req.RequestedRefundAmount, Method: req.Method, CustomerAccountInfo: req.CustomerAccountInfo, RefundVoucherKey: refundVoucherKey, RefundReason: req.RefundReason, Status: model.RefundStatusPending, } refund.Creator = userID refund.Updater = userID if s.refundApprovalCreation == nil { return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } attempt := buildAttemptFromDecision(decision, refund, decision.ChannelConfig, time.Now()) result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{ Refund: refund, Order: order, SubmitterAccountID: userID, Attempt: attempt, }) if err != nil { failedRefund := *refund failedRefund.ID = 0 failedRefund.ApprovalInstanceID = nil s.recordRefundFailure(ctx, constants.AuditActionRefundCreated, "提交退款申请失败", &failedRefund, order, err) return nil, err } resp := buildRefundResponse(result.Refund) resp.SubmitterName = result.SubmitterName resp.ApprovalProvider = constants.IntegrationProviderWeCom resp.ApprovalStatus = &result.ApprovalStatus resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus) return resp, 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, AssetIdentifier: req.AssetIdentifier, } requests, total, err := s.refundStore.List(ctx, opts, filters) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询退款申请列表失败") } submitterNames, err := s.loadSubmitterNames(ctx, refundSubmitterIDs(requests)) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败") } approvalSummaries, err := s.loadApprovalSummaries(ctx, requests) if err != nil { return nil, err } attemptResponses, err := s.loadAttemptResponses(ctx, requests) if err != nil { return nil, err } packageUsages, err := s.loadRefundPackageUsages(ctx, requests) if err != nil { return nil, err } items := make([]dto.RefundResponse, 0, len(requests)) for _, r := range requests { item := buildRefundResponse(r) item.SubmitterName = submitterNames[r.Creator] applyApprovalSummary(item, approvalSummaries, r) item.Attempts = attemptResponses[r.ID] applyRefundPackageUsage(item, packageUsages[r.ID]) items = append(items, *item) } return &dto.RefundListResponse{ Items: items, Total: total, Page: opts.Page, Size: opts.PageSize, }, nil } // GetByID 根据 ID 查询退款申请详情 // TriggerApproval 为历史退款申请主动补发企业微信审批。 func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResponse, error) { if s.refundApprovalCreation == nil { return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } refund, err := s.refundStore.GetByIDForOperation(ctx, id) if err != nil { return nil, errors.New(errors.CodeNotFound, "退款申请不存在") } result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID) if err != nil { return nil, err } resp := buildRefundResponse(result.Refund) resp.SubmitterName = result.SubmitterName resp.ApprovalProvider = constants.IntegrationProviderWeCom resp.ApprovalStatus = &result.ApprovalStatus resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus) return resp, nil } 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, "退款申请不存在") } resp := buildRefundResponse(refund) resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, refund.Creator) approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.RefundRequest{refund}) if err != nil { return nil, err } applyApprovalSummary(resp, approvalSummaries, refund) attemptResponses, err := s.loadAttemptResponses(ctx, []*model.RefundRequest{refund}) if err != nil { return nil, err } resp.Attempts = attemptResponses[refund.ID] packageUsages, err := s.loadRefundPackageUsages(ctx, []*model.RefundRequest{refund}) if err != nil { return nil, err } applyRefundPackageUsage(resp, packageUsages[refund.ID]) return resp, nil } // Approve 审批通过退款申请 // 条件更新 WHERE status=1,设置审批信息 // 事务提交成功后异步执行佣金回扣和退款后资产处理 func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error { if !legacyRefundManualEnabled() { return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态") } userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") } if err := ensureRefundProcessor(ctx); err != nil { return err } refund, err := s.refundStore.GetByIDForOperation(ctx, id) if err != nil { return errors.New(errors.CodeNotFound, "退款申请不存在") } ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo}) if refund.Status != model.RefundStatusPending { businessErr := errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过") s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr) return businessErr } if refund.ApprovalInstanceID != nil { businessErr := errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批") s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr) return businessErr } now := time.Now() approvedAmount := refund.RequestedRefundAmount if req.ApprovedRefundAmount != nil { approvedAmount = *req.ApprovedRefundAmount } order, err := s.orderStore.GetByID(ctx, refund.OrderID) if err != nil { businessErr := errors.New(errors.CodeNotFound, "订单不存在") s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr) return businessErr } if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, order); err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err) return err } // 事务内同步更新退款状态、订单支付状态和钱包回款,避免订单已退款但资金未退回。 beforeRefund := refundAuditState(refund) beforeOrder := map[string]any{"payment_status": order.PaymentStatus} err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 条件更新同时约束 approval_instance_id IS NULL:已关联审批实例的申请由企业微信决定, // 本地人工通过一律拒绝,并与 Reject/Return 的守卫保持一致(ENG-CONC-001)。 result := tx.Model(&model.RefundRequest{}). Where("id = ? AND status = ? AND approval_instance_id IS NULL", 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, "订单状态已变更,无法执行退款审批") } if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil { return err } if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil { return err } if err := s.appendCompletedNotification(ctx, tx, refund); err != nil { return err } if err := commissiondelivery.AppendRefundCommissionDeduct(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil { return err } if err := commissiondelivery.AppendRefundAssetProcess(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil { return err } return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundApproved, "通过退款审批", "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":approved", beforeRefund, beforeOrder, "退款已通过") }) if err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err) return err } return nil } // AppendCompletedNotification 在同一事务内补写退款完成通知事实。 // 供渠道原路退款在渠道明确成功时调用:该方式的退款完成时点晚于企微通过。 func (s *Service) AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error { return s.appendCompletedNotification(ctx, tx, refund) } // appendCompletedNotification 在退款业务事务内幂等写入目标店铺通知。 func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error { if refund == nil || refund.ShopID == nil { return nil } if s.notificationOutbox == nil { return errors.New(errors.CodeInternalError, "退款通知 Outbox 未配置") } refundID := fmt.Sprintf("%d", refund.ID) eventID := "refund:" + refundID + ":completed" _, err := s.notificationOutbox.AppendIdempotent(ctx, tx, outbox.Envelope{ EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification, PayloadVersion: constants.NotificationPayloadVersionV1, AggregateType: "refund", AggregateID: refundID, ResourceType: constants.NotificationRefTypeRefund, ResourceID: refundID, BusinessKey: eventID, Payload: notificationapp.AdminDynamicPayload{ TargetKind: constants.NotificationTargetKindShop, TargetID: *refund.ShopID, NotificationType: constants.NotificationTypeRefundCompleted, TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeRefund, RefID: refundID, RefKey: refund.RefundNo, }, }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入退款完成通知事件失败") } return nil } // refundWalletPayment 处理钱包支付订单的退款回款。 func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { if amount == 0 || order.PaymentMethod != model.PaymentMethodWallet { return nil } switch order.BuyerType { case model.BuyerTypeAgent: return s.refundAgentWalletPayment(ctx, tx, refund, order, amount, operatorID) case model.BuyerTypePersonal: return s.refundAssetWalletPayment(ctx, tx, refund, order, amount, operatorID) default: return errors.New(errors.CodeInvalidParam, "不支持的钱包退款买家类型") } } // refundAgentWalletPayment 将代理钱包支付的订单退款退回原扣款主钱包。 func (s *Service) refundAgentWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { if s.agentWalletRefundService == nil { return errors.New(errors.CodeInternalError, "代理主钱包退款能力未配置") } legacyPayerShopID, legacyRelatedShopID, _ := resolveAgentWalletRefundShopID(order) legacyDeductAmount := order.TotalAmount if order.ActualPaidAmount != nil && *order.ActualPaidAmount > 0 { legacyDeductAmount = *order.ActualPaidAmount } requestID := "" if value := middleware.GetRequestIDFromContext(ctx); value != nil { requestID = *value } correlationID := refund.RefundNo if correlationID == "" { correlationID = order.OrderNo } remark := fmt.Sprintf("订单%s退款退回预充值钱包", order.OrderNo) assetType, assetID, assetIdentifier := buildRefundWalletTransactionAssetSnapshot(order) _, err := s.agentWalletRefundService.RefundInTx(ctx, tx, walletapp.RefundCommand{ OrderID: order.ID, RefundID: refund.ID, Amount: amount, LegacyPayerShopID: legacyPayerShopID, LegacyDeductAmount: legacyDeductAmount, LegacyRelatedShopID: legacyRelatedShopID, UserID: operatorID, Creator: operatorID, AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier, Remark: remark, RequestID: requestID, CorrelationID: correlationID, }) return err } // resolveAgentWalletRefundShopID 兼容旧订单:没有扣款流水时按订单角色推导原扣款店铺。 func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) { if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate { if order.OperatorID == nil || *order.OperatorID == 0 { return 0, nil, errors.New(errors.CodeInternalError, "代理代购订单缺少原扣款代理") } relatedShopID := order.BuyerID return *order.OperatorID, &relatedShopID, nil } if order.OperatorID != nil && *order.OperatorID > 0 && order.OperatorType == model.OperatorAccountTypeAgent { return *order.OperatorID, nil, nil } if order.BuyerID == 0 { return 0, nil, errors.New(errors.CodeInternalError, "订单买家店铺为空") } return order.BuyerID, nil, nil } // refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。 func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { var existing model.AssetWalletTransaction err := tx.WithContext(ctx). Where("reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?", constants.ReferenceTypeRefund, refund.RefundNo, constants.AssetTransactionTypeRefund, constants.TransactionStatusSuccess). First(&existing).Error if err == nil { if existing.Amount != amount { return errors.New(errors.CodeConflict, "退款申请已存在不一致的资产钱包回款流水") } return nil } if err != gorm.ErrRecordNotFound { return errors.Wrap(errors.CodeDatabaseError, err, "复核资产钱包退款流水失败") } wallet, err := s.resolveAssetRefundWallet(tx, order) if err != nil { return err } balanceBefore := wallet.Balance if err := s.assetWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "退回资产钱包失败") } refType := constants.ReferenceTypeRefund refNo := refund.RefundNo remark := fmt.Sprintf("订单%s退款退回资产钱包", order.OrderNo) transaction := &model.AssetWalletTransaction{ AssetWalletID: wallet.ID, ResourceType: wallet.ResourceType, ResourceID: wallet.ResourceID, UserID: operatorID, TransactionType: constants.AssetTransactionTypeRefund, Amount: amount, BalanceBefore: balanceBefore, BalanceAfter: balanceBefore + amount, Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceNo: &refNo, Remark: &remark, Creator: operatorID, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, } if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建资产钱包退款流水失败") } return nil } // resolveAssetRefundWallet 优先按原扣款流水定位回款资产钱包。 func (s *Service) resolveAssetRefundWallet(tx *gorm.DB, order *model.Order) (*model.AssetWallet, error) { var deductTx model.AssetWalletTransaction err := tx.Where("reference_type = ? AND reference_no = ? AND transaction_type = ?", constants.ReferenceTypeOrder, order.OrderNo, constants.AssetTransactionTypeDeduct). Order("id ASC"). First(&deductTx).Error if err == nil { return lockAssetWalletByID(tx, deductTx.AssetWalletID) } if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原资产钱包扣款流水失败") } resourceType, resourceID, err := resolveOrderAssetWalletResource(order) if err != nil { return nil, err } return lockAssetWalletByResource(tx, resourceType, resourceID) } // resolveOrderAssetWalletResource 从订单载体推导资产钱包归属。 func resolveOrderAssetWalletResource(order *model.Order) (string, uint, error) { switch order.OrderType { case model.OrderTypeSingleCard: if order.IotCardID == nil || *order.IotCardID == 0 { return "", 0, errors.New(errors.CodeInternalError, "单卡订单缺少卡ID") } return constants.AssetWalletResourceTypeIotCard, *order.IotCardID, nil case model.OrderTypeDevice: if order.DeviceID == nil || *order.DeviceID == 0 { return "", 0, errors.New(errors.CodeInternalError, "设备订单缺少设备ID") } return constants.AssetWalletResourceTypeDevice, *order.DeviceID, nil default: return "", 0, errors.New(errors.CodeInvalidParam, "未知订单类型") } } // lockAssetWalletByID 锁定资产钱包。 func lockAssetWalletByID(tx *gorm.DB, walletID uint) (*model.AssetWallet, error) { var wallet model.AssetWallet if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", walletID). First(&wallet).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定资产钱包失败") } return &wallet, nil } // lockAssetWalletByResource 按资产归属锁定资产钱包。 func lockAssetWalletByResource(tx *gorm.DB, resourceType string, resourceID uint) (*model.AssetWallet, error) { var wallet model.AssetWallet if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). First(&wallet).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定资产钱包失败") } return &wallet, nil } // Reject 审批拒绝退款申请 // 条件更新 WHERE status=1 func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequest) error { if !legacyRefundManualEnabled() { return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态") } userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") } if err := ensureRefundProcessor(ctx); err != nil { return err } var refund model.RefundRequest var order model.Order now := time.Now() err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil { return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") } if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") } beforeRefund := refundAuditState(&refund) result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ? AND approval_instance_id IS NULL", 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 s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundRejected, "拒绝退款审批", "refund:"+strconv.FormatUint(uint64(id), 10)+":rejected", beforeRefund, nil, "退款已拒绝") }) if err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err) } return err } func legacyRefundManualEnabled() bool { cfg := config.Get() return cfg == nil || cfg.Approval.LegacyRefundManualEnabled } // 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 } var refund model.RefundRequest var order model.Order now := time.Now() err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil { return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") } if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") } beforeRefund := refundAuditState(&refund) result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ? AND approval_instance_id IS NULL", 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 s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundReturned, "退回退款申请", "", beforeRefund, nil, "退款申请已退回") }) if err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundReturned, "退回退款申请失败", &refund, &order, err) } return err } // 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(已退回),修改部分字段后重新进入待审批状态 // Resubmit 修改并重提未成功退款申请 // POST /api/admin/refunds/:id/resubmit // 仅已拒绝、已退回或原路退款失败且不存在审批异常的申请可修改重提;每次重提新增一条不可变 // 审批尝试记录与一个新的企业微信审批实例,历史材料与审批结果不被覆盖。金额与方式的变更 // 必须重新审批,不得沿用旧实例。 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.GetByIDForOperation(ctx, id) if err != nil { return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") } ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo}) if err := s.resubmitPrecheck(refund); err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err) return err } order, err := s.orderStore.GetByID(ctx, refund.OrderID) if err != nil { businessErr := errors.New(errors.CodeNotFound, "订单不存在") s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr) return businessErr } decision, err := s.decideRefundMethods(ctx, order) if err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) return err } // 方式缺省沿用原方式,金额缺省沿用原金额。 method := refund.Method if req.Method != nil && strings.TrimSpace(*req.Method) != "" { method = *req.Method } if err := validateRefundMethod(decision, method); err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) return err } requestedRefundAmount := refund.RequestedRefundAmount if req.RequestedRefundAmount != nil { requestedRefundAmount = *req.RequestedRefundAmount } if err := validateFrozenRefundAmount(requestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) return err } refundVoucherKey := refund.RefundVoucherKey if req.RefundVoucherKey != nil { normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey) if normErr != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, normErr) return normErr } refundVoucherKey = normalized } refundReason := refund.RefundReason if req.RefundReason != nil { refundReason = *req.RefundReason } customerAccountInfo := refund.CustomerAccountInfo if req.CustomerAccountInfo != nil { customerAccountInfo = *req.CustomerAccountInfo } if err := validateCustomerAccountMaterial(method, customerAccountInfo, refundVoucherKey); err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err) return err } // 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。 updated := *refund updated.Method = method updated.RequestedRefundAmount = requestedRefundAmount updated.FrozenActualReceivedAmount = decision.FrozenActualReceivedAmount updated.ActualReceivedAmount = decision.FrozenActualReceivedAmount updated.RefundVoucherKey = refundVoucherKey updated.RefundReason = refundReason updated.CustomerAccountInfo = customerAccountInfo updated.Creator = userID attempt := buildAttemptFromDecision(decision, &updated, decision.ChannelConfig, time.Now()) if s.refundApprovalCreation == nil { return errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{ Refund: &updated, Attempt: attempt, }); err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) return err } return nil } // resubmitPrecheck 校验退款申请是否处于可重提状态且不存在审批异常。 // 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由超级管理员线下处理。 func (s *Service) resubmitPrecheck(refund *model.RefundRequest) error { if refund == nil { return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") } if refund.AnomalyFlag != 0 { return errors.New(errors.CodeInvalidStatus, "该退款申请存在审批异常,需超级管理员线下处理,不支持重提") } for _, status := range model.RefundResubmittableStatuses() { if refund.Status == status { return nil } } return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") } // pendingWithdrawalReject 是一条待审提现的本次拒绝事实。 // releaseAmount 是本次实际释放额:接入企业微信审批的申请取尝试记录事实, // 从未关联审批实例的存量申请取申请金额;已结算尝试的本次释放额为 0。 type pendingWithdrawalReject struct { Withdrawal model.CommissionWithdrawalRequest Before map[string]any ReleaseAmount int64 } // collectPendingWithdrawalRejects 在钱包加锁之前锁定该店铺全部待审提现并算出本次释放额。 // 加锁顺序:申请行(FOR UPDATE)→ 尝试行(FOR UPDATE),钱包行留给调用方最后加锁。 func (s *Service) collectPendingWithdrawalRejects( ctx context.Context, tx *gorm.DB, shopID uint, ) ([]pendingWithdrawalReject, error) { var withdrawals []model.CommissionWithdrawalRequest if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalStatusPending). Order("id ASC").Find(&withdrawals).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审核佣金提现失败") } if len(withdrawals) == 0 { return nil, nil } requestIDs := make([]uint, 0, len(withdrawals)) for i := range withdrawals { requestIDs = append(requestIDs, withdrawals[i].ID) } releasedAmounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, time.Now().UTC()) if err != nil { return nil, err } withAttempts, err := postgres.CountRequestsWithAttemptsInTx(ctx, tx, requestIDs) if err != nil { return nil, err } rejects := make([]pendingWithdrawalReject, 0, len(withdrawals)) for i := range withdrawals { withdrawal := withdrawals[i] releaseAmount := withdrawal.Amount if _, hasAttempt := withAttempts[withdrawal.ID]; hasAttempt { // 接入企业微信审批的申请:释放金额取本次实际释放的尝试事实。 releaseAmount = releasedAmounts[withdrawal.ID] } rejects = append(rejects, pendingWithdrawalReject{ Withdrawal: withdrawal, Before: withdrawalRejectState(&withdrawal), ReleaseAmount: releaseAmount, }) } return rejects, nil } // applyPendingWithdrawalRejects 按本次实际释放额解冻钱包、置驳回并写流水与审计。 // 释放额为 0 时不写无意义的 0 金额流水,审计也不带钱包流水资源。 func (s *Service) applyPendingWithdrawalRejects( ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, refund *model.RefundRequest, rejects []pendingWithdrawalReject, ) error { if len(rejects) == 0 { return nil } var shop model.Shop if err := tx.WithContext(ctx).First(&shop, wallet.ShopID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺失败") } remark := "退款佣金回扣,自动拒绝提现" for i := range rejects { reject := &rejects[i] w := &reject.Withdrawal releaseAmount := reject.ReleaseAmount if releaseAmount > 0 { if err := s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, releaseAmount); err != nil { return errors.Wrap(errors.CodeInternalError, err, "解冻提现冻结余额失败") } } var transaction *model.AgentWalletTransaction if releaseAmount > 0 { // 只有本次确实释放了冻结才写流水,避免产生无意义的 0 金额流水。 refType := constants.ReferenceTypeWithdrawal transaction = &model.AgentWalletTransaction{ AgentWalletID: wallet.ID, ShopID: wallet.ShopID, UserID: refund.Creator, TransactionType: constants.AgentTransactionTypeRefund, Amount: releaseAmount, BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance, Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &w.ID, Remark: &remark, Creator: refund.Creator, ShopIDTag: wallet.ShopID, } if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建自动拒绝提现流水失败") } } now := time.Now() result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). Where("id = ? AND status = ?", w.ID, constants.WithdrawalStatusPending). Updates(map[string]any{ "status": constants.WithdrawalStatusRejected, "processed_at": now, "reject_reason": remark, "updated_at": now, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "拒绝待审核提现失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "提现申请状态已变化") } w.Status = constants.WithdrawalStatusRejected w.ProcessedAt = &now w.RejectReason = remark frozenBefore := wallet.FrozenBalance wallet.FrozenBalance = frozenBefore - releaseAmount if err := s.appendWithdrawalRejectAudit(ctx, tx, w, wallet, transaction, &shop, reject.Before, releaseAmount, frozenBefore); err != nil { return err } } return nil } func withdrawalRejectState(w *model.CommissionWithdrawalRequest) map[string]any { return map[string]any{ "status": w.Status, "amount": w.Amount, "fee": w.Fee, "actual_amount": w.ActualAmount, "withdrawal_method": w.WithdrawalMethod, "processed_at": w.ProcessedAt, "reject_reason": w.RejectReason, } } func (s *Service) appendWithdrawalRejectAudit( ctx context.Context, tx *gorm.DB, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, shop *model.Shop, before map[string]any, releaseAmount int64, frozenBefore int64, ) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置") } primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget, before, withdrawalRejectState(withdrawal)) primary.SubjectVisibility = constants.AuditSubjectResult primary.SubjectSummary = "退款佣金回扣自动拒绝提现" // 审计前后冻结额一律取本次实际释放额,避免与实际释放不一致甚至为负。 walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet, map[string]any{"balance": wallet.Balance, "frozen_balance": frozenBefore}, map[string]any{"balance": wallet.Balance, "frozen_balance": frozenBefore - releaseAmount}) walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly shopResource := audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop) shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly resources := []audit.ResourceInput{primary, walletResource, shopResource} if transaction != nil { transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction) transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, transactionResource) } // 与分销/提现终审路径统一口径:这笔审计属于「要求成功必达」的拒绝事实, // 必须与业务事实同事务原子提交,因此使用非吞错变体并显式返回错误。 if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{ EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(withdrawal.ID), 10) + ":refund-rejected", ActionCode: constants.AuditActionCommissionWithdrawalRejected, Summary: "退款佣金回扣自动拒绝提现", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, CorrelationID: withdrawal.WithdrawalNo, Metadata: map[string]any{"amount": withdrawal.Amount, "released_amount": releaseAmount, "status": withdrawal.Status}, Resources: resources, }); err != nil { return errors.Wrap(errors.CodeDatabaseError, 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 } if refund.AssetReset { return } ctx = auditcontext.With(ctx, auditcontext.Context{ ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundAssetPostProcessing, ActorName: "退款资产自动后处理任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo, }) // 查询关联订单 var order model.Order if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, nil, err) logger.Error("退款资产处理:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err)) return } recordFailure := func(err error) { s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, &order, err) } // 确定资产类型和 ID var assetType string var assetID uint switch order.OrderType { case model.OrderTypeSingleCard: if order.IotCardID == nil { recordFailure(errors.New(errors.CodeInternalError, "退款单卡订单缺少资产ID")) 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 { recordFailure(errors.New(errors.CodeInternalError, "退款设备订单缺少资产ID")) logger.Error("退款资产处理:设备订单缺少 device_id", zap.Uint("order_id", order.ID)) return } assetType = "device" assetID = *order.DeviceID default: recordFailure(errors.New(errors.CodeInvalidParam, "退款订单资产类型无效")) logger.Error("退款资产处理:未知订单类型", zap.String("order_type", order.OrderType)) return } // 1. 按退款单精准失效套餐(仅处理本次退款订单关联套餐) if s.packageActivationService == nil { businessErr := errors.New(errors.CodeServiceUnavailable, "退款套餐处理能力未配置") recordFailure(businessErr) 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 { recordFailure(err) 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 { recordFailure(err) 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 { recordFailure(err) 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. 无可用主套餐时才停机;退款不再重置世代或重建钱包。 if !s.stopAsset(ctx, assetType, assetID) { recordFailure(errors.New(errors.CodeServiceUnavailable, "退款资产停机处理失败")) return } } // 4. 标记退款后资产处理已完成 if err := s.markRefundAssetProcessed(ctx, refundID); err != nil { recordFailure(err) logger.Error("退款资产处理:更新处理标记失败", zap.Uint("refund_id", refundID), zap.Error(err)) } } // stopAsset 根据资产类型执行停机操作 func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) bool { 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 false } if s.stopResumeService == nil { logger.Error("退款资产处理:单卡停机服务未注入", zap.Uint("card_id", assetID)) return false } if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil { logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err)) return false } case "device": if s.stopResumeService == nil { logger.Error("退款资产处理:设备停机服务未注入", zap.Uint("device_id", assetID)) return false } var cards []model.IotCard if err := s.db.WithContext(ctx).Model(&model.IotCard{}). Joins("JOIN tb_device_sim_binding binding ON binding.iot_card_id = tb_iot_card.id AND binding.deleted_at IS NULL"). Where("binding.device_id = ? AND binding.bind_status = ?", assetID, constants.BindStatusBound). Find(&cards).Error; err != nil { logger.Error("退款资产处理:查询设备绑定卡失败", zap.Uint("device_id", assetID), zap.Error(err)) return false } for _, card := range cards { if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil { logger.Error("退款资产处理:设备绑定卡停机失败", zap.Uint("device_id", assetID), zap.Uint("card_id", card.ID), zap.Error(err)) return false } } } return true } // 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 } // validateApprovedRefundAmount 校验审批退款金额不能超过申请金额和订单实收金额。 func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount int64, order *model.Order) error { if approvedAmount < 0 { return errors.New(errors.CodeInvalidParam, "审批退款金额不能小于0") } if approvedAmount > requestedRefundAmount { return errors.New(errors.CodeInvalidParam, "审批退款金额不能大于申请退款金额") } return validateRequestedRefundAmountByOrder(approvedAmount, order) } func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) { if len(keys) == 0 { return nil, errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证") } if len(keys) > 5 { return nil, errors.New(errors.CodeInvalidParam, "退款凭证最多上传5个") } normalized := make(model.StringJSONBArray, 0, len(keys)) for _, key := range keys { key = strings.TrimSpace(key) if key == "" { return nil, errors.New(errors.CodeInvalidParam, "退款凭证对象存储 Key 不能为空") } normalized = append(normalized, key) } return normalized, 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, RefundVoucherKey: []string(r.RefundVoucherKey), RefundReason: r.RefundReason, Status: r.Status, StatusName: constants.GetRefundStatusName(r.Status), Method: r.Method, MethodName: constants.RefundMethodName(r.Method), FrozenActualReceivedAmount: r.FrozenActualReceivedAmount, CustomerAccountInfo: r.CustomerAccountInfo, ChannelRefundStatus: r.ChannelRefundStatus, ChannelRefundStatusName: constants.RefundChannelStatusName(r.ChannelRefundStatus), ChannelRefundNo: r.ChannelRefundNo, ChannelRefundRequestNo: r.ChannelRefundRequestNo, ChannelRefundAmount: r.ChannelRefundAmount, FailureReason: r.FailureReason, FailureReasonName: constants.RefundFailureReasonName(r.FailureReason), FailureMessage: r.FailureMessage, AnomalyFlag: r.AnomalyFlag, AnomalyReason: r.AnomalyReason, LatestAttemptID: r.LatestAttemptID, LatestApprovalInstanceID: r.LatestApprovalInstanceID, Attempts: []dto.RefundAttemptResponse{}, ProcessorID: r.ProcessorID, RejectReason: r.RejectReason, Remark: r.Remark, CommissionDeducted: r.CommissionDeducted, AssetReset: r.AssetReset, SubmitterID: r.Creator, ApprovalInstanceID: r.ApprovalInstanceID, 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") } if r.ChannelRefundedAt != nil { resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05") } return resp } type approvalSummary struct { Provider string Status int } func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.RefundRequest) (map[uint]approvalSummary, error) { ids := make([]uint, 0, len(refunds)) seen := make(map[uint]struct{}, len(refunds)) for _, refund := range refunds { if refund == nil || refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID == 0 { continue } id := *refund.ApprovalInstanceID if _, exists := seen[id]; exists { continue } seen[id] = struct{}{} ids = append(ids, id) } summaries := make(map[uint]approvalSummary, len(ids)) if len(ids) == 0 { return summaries, nil } var instances []model.ApprovalInstance if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批状态失败") } for _, instance := range instances { summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status} } return summaries, nil } // applyApprovalSummary 用审批实例摘要回填响应的审批状态。 // 优先取最新审批尝试关联的实例;最新实例摘要缺失或存量数据未写入时回退主表关联实例。 func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, refund *model.RefundRequest) { if response == nil || refund == nil { return } summary, exists := summaries[refund.LatestApprovalInstanceID] if !exists && refund.ApprovalInstanceID != nil { summary, exists = summaries[*refund.ApprovalInstanceID] } if !exists { return } status := summary.Status response.ApprovalProvider = summary.Provider response.ApprovalStatus = &status response.ApprovalStatusName = constants.GetApprovalStatusName(status) } func refundSubmitterIDs(requests []*model.RefundRequest) []uint { ids := make([]uint, 0, len(requests)) for _, request := range requests { if request != nil && request.Creator > 0 { ids = append(ids, request.Creator) } } return ids } func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint]string, error) { accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, ids) if err != nil { return nil, err } names := make(map[uint]string, len(accounts)) for _, account := range accounts { names[account.ID] = account.Username } return names, nil } func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string { names, err := s.loadSubmitterNames(ctx, []uint{id}) if err != nil { s.logger.Warn("查询退款提交人失败", zap.Uint("submitter_id", id), zap.Error(err)) return "" } return names[id] } // buildRefundWalletTransactionAssetSnapshot 从订单中生成退款流水的资产快照。 func buildRefundWalletTransactionAssetSnapshot(order *model.Order) (string, uint, string) { if order == nil { return "", 0, "" } switch order.OrderType { case model.OrderTypeSingleCard: if order.IotCardID == nil { return constants.AssetTypeIotCard, 0, order.AssetIdentifier } return constants.AssetTypeIotCard, *order.IotCardID, order.AssetIdentifier case model.OrderTypeDevice: if order.DeviceID == nil { return constants.AssetTypeDevice, 0, order.AssetIdentifier } return constants.AssetTypeDevice, *order.DeviceID, order.AssetIdentifier default: return "", 0, order.AssetIdentifier } }