package refund import ( "context" "strconv" "strings" "gorm.io/gorm" "gorm.io/gorm/clause" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" refundapproval "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" "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/pkg/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // Handle 幂等处理退款的渠道无关审批终态。 func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { if s == nil || s.db == nil || event.BusinessType != constants.ApprovalBusinessTypeRefund || event.BusinessID == 0 || event.InstanceID == 0 { return errors.New(errors.CodeInvalidParam, "退款审批终态参数无效") } switch event.Decision { case constants.ApprovalDecisionApproved: return s.applyApprovedDecision(ctx, event) case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted: return s.applyClosedDecision(ctx, event) case constants.ApprovalDecisionRevokedAfterApproved: return s.applyRevokedAfterApproved(ctx, event) default: return errors.New(errors.CodeInvalidParam, "不支持的退款审批终态") } } // applyRevokedAfterApproved 处理企业微信通过后撤销。 // // 不回滚已失效的套餐权益、不取消已提交的渠道退款、也不恢复订单退款状态:这些事实在通过时 // 已经成立。系统只标记审批异常并禁止后续自动重提,交由超级管理员线下处理。 func (s *Service) applyRevokedAfterApproved(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) var refund model.RefundRequest err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { target, _, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID) if err != nil { return err } if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") } if refund.AnomalyFlag == 1 { return nil } beforeRefund := refundAuditState(&refund) result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND anomaly_flag = 0", refund.ID). Updates(map[string]any{ "anomaly_flag": 1, "anomaly_reason": "企业微信通过后撤销,需超级管理员线下处理", "failure_reason": constants.RefundFailureRevokedAfterApproved, "updated_at": event.OccurredAt, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记退款审批异常失败") } if result.RowsAffected != 1 { return nil } refund.AnomalyFlag = 1 refund.AnomalyReason = "企业微信通过后撤销,需超级管理员线下处理" refund.FailureReason = constants.RefundFailureRevokedAfterApproved return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundAnomalyFlagged, "标记退款审批异常", "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":revoked", beforeRefund, nil, "退款审批通过后撤销") }) if err != nil { return err } return nil } func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) var refund model.RefundRequest var order model.Order changed := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 业务标识解析:审批尝试记录优先,退款申请兜底(兼容尚未接入尝试模式的存量申请)。 target, attempt, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID) if err != nil { return err } if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") } if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved && refund.Status != model.RefundStatusChannelProcessing { return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过") } if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款关联订单失败") } beforeRefund := refundAuditState(&refund) beforeOrder := map[string]any{"payment_status": order.PaymentStatus} approvedAmount := refund.RequestedRefundAmount if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil { return err } if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil { return err } // 原路退款只登记待执行事实:退款单转入原路处理中,订单在渠道明确成功前保持已支付。 // 客户收款信息与退回原钱包在审批通过时即完成,订单同时置为已退款。 originalRoute := refund.Method == constants.RefundMethodOriginalRoute if refund.Status == model.RefundStatusPending { changed = true targetStatus := model.RefundStatusApproved if originalRoute { targetStatus = model.RefundStatusChannelProcessing } result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). Updates(map[string]any{ "status": targetStatus, "processed_at": event.OccurredAt, "approved_refund_amount": approvedAmount, "remark": "企业微信审批通过", "failure_reason": "", "failure_message": "", "updated_at": event.OccurredAt, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "完成退款审批申请失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "退款申请状态已变化") } refund.Status = targetStatus } if !originalRoute { switch order.PaymentStatus { case model.PaymentStatusPaid: changed = true result := tx.WithContext(ctx).Model(&model.Order{}). Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid). Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "订单退款状态已变化") } order.PaymentStatus = model.PaymentStatusRefunded case model.PaymentStatusRefunded: default: return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款") } } else { if order.PaymentStatus != model.PaymentStatusPaid && order.PaymentStatus != model.PaymentStatusRefunded { return errors.New(errors.CodeInvalidStatus, "订单状态不允许原路退款") } if err := s.prepareChannelRefundInTx(ctx, tx, &refund, attempt); err != nil { return err } } if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil { return err } // 员工代收款冲销:接入点只在既有退款成功事务内,以 bill_id+refund_id 唯一事实幂等, // 不依赖下方 changed 门;来源订单未建账时直接跳过,不阻断退款。 if s.refundOffset == nil { return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置") } if err := s.refundOffset.ApplyInTx(ctx, tx, employeecollectionapp.RefundOffsetSource{ RefundID: refund.ID, OrderID: order.ID, RefundAmount: approvedAmount, }); err != nil { return err } if !originalRoute { 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 } if !changed { return nil } 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 } // prepareChannelRefundInTx 在企微通过事务内登记原路退款的待执行事实。 // // 只写本地事实与可靠事件:请求号在提交时已冻结到审批尝试记录,这里把它落到退款单并转入 // 原路处理中;真正的渠道调用由可靠事件驱动的事务外消费者执行(ENG-TX-001)。 func (s *Service) prepareChannelRefundInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error { if s.channelRefund == nil { return errors.New(errors.CodeInternalError, "渠道原路退款能力未配置") } return s.channelRefund.PrepareInTx(ctx, tx, refund, attempt) } func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) reason := map[string]string{ constants.ApprovalDecisionRejected: "企业微信审批已拒绝", constants.ApprovalDecisionCancelled: "企业微信审批已撤销", constants.ApprovalDecisionDeleted: "企业微信审批已删除", }[event.Decision] var refund model.RefundRequest var order model.Order err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") } if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID { return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致") } if refund.Status == model.RefundStatusRejected { return nil } if refund.Status != model.RefundStatusPending { 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 = ?", refund.ID, model.RefundStatusPending). Updates(map[string]any{ "status": model.RefundStatusRejected, "processed_at": event.OccurredAt, "reject_reason": strings.TrimSpace(reason), "updated_at": event.OccurredAt, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束退款审批申请失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "退款申请状态已变化") } return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundRejected, "拒绝退款审批", "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":rejected", beforeRefund, nil, "退款已拒绝") }) if err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err) } return err } // ProcessCommissionDeduction 处理退款佣金回溯后处理。 // 返回非 nil 表示后处理尚未闭合(准入未满足、冻结实收非正或订单佣金未终态), // 由 Outbox 与周期性补偿任务重试;返回 nil 表示已闭合并已落库全部应有事实。 func (s *Service) ProcessCommissionDeduction(ctx context.Context, refundID uint) error { return s.clawbackCommission(ctx, refundID) } func (s *Service) ProcessAssetPostProcessing(ctx context.Context, refundID uint) error { s.handleRefundAssetProcessing(ctx, refundID) var refund model.RefundRequest if err := s.db.WithContext(ctx).Select("asset_reset").First(&refund, refundID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "复核退款资产后处理状态失败") } if !refund.AssetReset { return errors.New(errors.CodeServiceUnavailable, "退款资产后处理尚未完成") } return nil } var _ approvalapp.BusinessDecisionHandler = (*Service)(nil)