package refund import ( "context" "strconv" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // appendRefundAudit 在调用方事务内追加退款状态及完整关联资源。 func (s *Service) appendRefundAudit( ctx context.Context, tx *gorm.DB, refundID uint, actionCode string, summary string, eventID string, beforeRefund map[string]any, beforeOrder map[string]any, subjectSummary string, ) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置") } var refund model.RefundRequest if err := tx.WithContext(ctx).First(&refund, refundID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审计快照失败") } var order model.Order if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单审计快照失败") } primary := audit.RefundResource(&refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) primary.BeforeData = beforeRefund primary.AfterData = refundAuditState(&refund) primary.SubjectVisibility = constants.AuditSubjectResult primary.SubjectSummary = subjectSummary orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder) orderResource.BeforeData = beforeOrder orderResource.AfterData = map[string]any{"payment_status": order.PaymentStatus} orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly resources := []audit.ResourceInput{primary, orderResource} if refund.ApprovalInstanceID != nil { var approval model.ApprovalInstance if err := tx.WithContext(ctx).First(&approval, *refund.ApprovalInstanceID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败") } resource := audit.ApprovalInstanceResource(&approval, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundApproval, nil, nil) resource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, resource) } asset, err := audit.RefundAssetResource(ctx, tx, &order, subjectSummary) if err != nil { return err } if asset != nil { resources = append(resources, *asset) } if actionCode == constants.AuditActionRefundApproved || actionCode == constants.AuditActionRefundAssetProcessed { chain, err := refundChainAuditResources(ctx, tx, &refund, &order) if err != nil { return err } resources = append(resources, chain...) } return s.auditWriter.Append(ctx, tx, audit.AppendInput{ EventID: eventID, ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, CorrelationID: refund.RefundNo, Metadata: map[string]any{ "requested_refund_amount": refund.RequestedRefundAmount, "approved_refund_amount": refund.ApprovedRefundAmount, }, Resources: resources, }) } // refundChainAuditResources 汇总退款已形成的资金、佣金、套餐和通知事实引用。 func refundChainAuditResources(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) ([]audit.ResourceInput, error) { resources, err := refundFinanceAuditResources(ctx, tx, refund, order) if err != nil { return nil, err } var commissions []model.CommissionRecord if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&commissions).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金审计快照失败") } for i := range commissions { resource := audit.CommissionRecordResource(&commissions[i], nil, nil) resource.Relation = constants.AuditResourceRelationReference resource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, resource) } var usages []model.PackageUsage if err := tx.WithContext(ctx).Where("refund_id = ?", refund.ID).Order("id ASC").Find(&usages).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款套餐权益审计快照失败") } for i := range usages { resource := audit.PackageUsageResource(&usages[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundPackageUsage, nil, nil) resource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, resource) } var notification model.OutboxEvent notificationEventID := "refund:" + strconv.FormatUint(uint64(refund.ID), 10) + ":completed" if err := tx.WithContext(ctx).Where("event_id = ?", notificationEventID).First(¬ification).Error; err == nil { id := strconv.FormatUint(uint64(notification.ID), 10) resources = append(resources, audit.ResourceInput{ Type: constants.AuditResourceOutboxEvent, ID: &id, Key: notification.EventID, DisplayName: notification.EventID, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundNotification, IdentitySnapshot: map[string]any{ "event_id": notification.EventID, "event_type": notification.EventType, "aggregate_type": notification.AggregateType, "aggregate_id": notification.AggregateID, "resource_type": notification.ResourceType, "resource_id": notification.ResourceID, "business_key": notification.BusinessKey, }, SubjectVisibility: constants.AuditSubjectInternalOnly, }) } else if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款通知审计快照失败") } return resources, nil } // refundFinanceAuditResources 关联原扣款与退款流水,但不替代钱包流水权威事实。 func refundFinanceAuditResources(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) ([]audit.ResourceInput, error) { var agentTransactions []model.AgentWalletTransaction if err := tx.WithContext(ctx).Unscoped(). Where("(reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?) OR (reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?)", constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess, constants.ReferenceTypeRefund, refund.ID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess). Order("id DESC").Find(&agentTransactions).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款代理钱包流水审计快照失败") } resources := make([]audit.ResourceInput, 0, len(agentTransactions)*2+2) wallets := make(map[uint]struct{}, len(agentTransactions)) for i := range agentTransactions { transaction := &agentTransactions[i] if _, exists := wallets[transaction.AgentWalletID]; !exists { var wallet model.AgentWallet if err := tx.WithContext(ctx).First(&wallet, transaction.AgentWalletID).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款代理钱包审计快照失败") } resources = append(resources, agentWalletRefundResource(&wallet, transaction)) wallets[transaction.AgentWalletID] = struct{}{} } resources = append(resources, agentWalletRefundTransactionResource(transaction, refund.ID)) } var assetTransactions []model.AssetWalletTransaction if err := tx.WithContext(ctx).Unscoped(). Where("(reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?) OR (reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?)", constants.ReferenceTypeOrder, order.OrderNo, constants.AssetTransactionTypeDeduct, constants.TransactionStatusSuccess, constants.ReferenceTypeRefund, refund.RefundNo, constants.AssetTransactionTypeRefund, constants.TransactionStatusSuccess). Order("id DESC").Find(&assetTransactions).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款资产钱包流水审计快照失败") } assetWallets := make(map[uint]struct{}, len(assetTransactions)) for i := range assetTransactions { transaction := &assetTransactions[i] if _, exists := assetWallets[transaction.AssetWalletID]; !exists { var wallet model.AssetWallet if err := tx.WithContext(ctx).First(&wallet, transaction.AssetWalletID).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款资产钱包审计快照失败") } resources = append(resources, assetWalletRefundResource(&wallet, transaction)) assetWallets[transaction.AssetWalletID] = struct{}{} } resources = append(resources, assetWalletRefundTransactionResource(transaction, refund.RefundNo)) } return resources, nil } // agentWalletRefundResource 构造代理钱包退款余额变化资源。 func agentWalletRefundResource(wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) audit.ResourceInput { id := strconv.FormatUint(uint64(wallet.ID), 10) return audit.ResourceInput{ Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理钱包 " + id, Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundWallet, IdentitySnapshot: map[string]any{ "id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType, "currency": wallet.Currency, "status": wallet.Status, }, BeforeData: map[string]any{"balance": transaction.BalanceBefore}, AfterData: map[string]any{"balance": transaction.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly, } } // agentWalletRefundTransactionResource 区分原扣款流水和退款回充流水。 func agentWalletRefundTransactionResource(transaction *model.AgentWalletTransaction, refundID uint) audit.ResourceInput { id := strconv.FormatUint(uint64(transaction.ID), 10) role := constants.AuditResourceRoleRefundOriginalTransaction relation := constants.AuditResourceRelationReference if transaction.ReferenceType != nil && *transaction.ReferenceType == constants.ReferenceTypeRefund && transaction.ReferenceID != nil && *transaction.ReferenceID == refundID { role, relation = constants.AuditResourceRoleRefundTransaction, constants.AuditResourceRelationAffected } return audit.ResourceInput{ Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id, Relation: relation, Role: role, IdentitySnapshot: map[string]any{ "id": transaction.ID, "agent_wallet_id": transaction.AgentWalletID, "shop_id": transaction.ShopID, "transaction_type": transaction.TransactionType, "transaction_subtype": transaction.TransactionSubtype, "reference_type": transaction.ReferenceType, "reference_id": transaction.ReferenceID, "status": transaction.Status, }, AfterData: map[string]any{ "amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter, }, SubjectVisibility: constants.AuditSubjectInternalOnly, } } // assetWalletRefundResource 构造资产钱包退款余额变化资源。 func assetWalletRefundResource(wallet *model.AssetWallet, transaction *model.AssetWalletTransaction) audit.ResourceInput { id := strconv.FormatUint(uint64(wallet.ID), 10) return audit.ResourceInput{ Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: "资产钱包 " + id, Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundWallet, IdentitySnapshot: map[string]any{ "id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID, "currency": wallet.Currency, "shop_id_tag": wallet.ShopIDTag, "enterprise_id_tag": wallet.EnterpriseIDTag, }, BeforeData: map[string]any{"balance": transaction.BalanceBefore}, AfterData: map[string]any{"balance": transaction.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly, } } // assetWalletRefundTransactionResource 区分资产钱包原扣款流水和退款回充流水。 func assetWalletRefundTransactionResource(transaction *model.AssetWalletTransaction, refundNo string) audit.ResourceInput { id := strconv.FormatUint(uint64(transaction.ID), 10) role := constants.AuditResourceRoleRefundOriginalTransaction relation := constants.AuditResourceRelationReference if transaction.ReferenceType != nil && *transaction.ReferenceType == constants.ReferenceTypeRefund && transaction.ReferenceNo != nil && *transaction.ReferenceNo == refundNo { role, relation = constants.AuditResourceRoleRefundTransaction, constants.AuditResourceRelationAffected } return audit.ResourceInput{ Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: "资产钱包流水 " + id, Relation: relation, Role: role, IdentitySnapshot: map[string]any{ "id": transaction.ID, "asset_wallet_id": transaction.AssetWalletID, "resource_type": transaction.ResourceType, "resource_id": transaction.ResourceID, "transaction_type": transaction.TransactionType, "reference_type": transaction.ReferenceType, "reference_no": transaction.ReferenceNo, "status": transaction.Status, }, AfterData: map[string]any{ "amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter, }, SubjectVisibility: constants.AuditSubjectInternalOnly, } } // appendCommissionAudit 将佣金失效、钱包扣减和回扣流水绑定在同一事务。 func (s *Service) appendCommissionAudit(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, commission *model.CommissionRecord, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置") } primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) primary.SubjectVisibility = constants.AuditSubjectResult primary.SubjectSummary = "退款佣金已处理" commissionResource := audit.CommissionRecordResource(commission, map[string]any{"status": constants.CommissionStatusReleased}, map[string]any{"status": constants.CommissionStatusInvalid}) commissionResource.SubjectVisibility = constants.AuditSubjectInternalOnly transactionResource := agentWalletRefundTransactionResource(transaction, refund.ID) transactionResource.Relation = constants.AuditResourceRelationAffected transactionResource.Role = constants.AuditResourceRoleRefundTransaction resources := []audit.ResourceInput{primary, commissionResource, agentWalletRefundResource(wallet, transaction), transactionResource} var order model.Order if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联订单审计快照失败") } orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder) orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, orderResource) var shop model.Shop if err := tx.WithContext(ctx).First(&shop, commission.ShopID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联店铺审计快照失败") } shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundCommission) shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, shopResource) return s.auditWriter.Append(ctx, tx, audit.AppendInput{ EventID: "refund:" + strconv.FormatUint(uint64(refund.ID), 10) + ":commission:" + strconv.FormatUint(uint64(commission.ID), 10) + ":invalidated", ActionCode: constants.AuditActionRefundCommissionInvalidated, Summary: "退款失效佣金", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, CorrelationID: refund.RefundNo, Resources: resources, }) } // recordRefundFailure 在原业务事务回滚后记录已定位退款的失败或拒绝。 func (s *Service) recordRefundFailure(ctx context.Context, actionCode, summary string, refund *model.RefundRequest, order *model.Order, businessErr error) { if businessErr == nil || refund == nil || refund.RefundNo == "" || s.auditWriter == nil || s.db == nil { return } primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) primary.BeforeData = refundAuditState(refund) primary.SubjectVisibility = constants.AuditSubjectInternalOnly resources := []audit.ResourceInput{primary} if order != nil && (order.ID > 0 || order.OrderNo != "") { resource := audit.OrderResource(order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder) resource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, resource) } s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, CorrelationID: refund.RefundNo, Resources: resources, }, businessErr) } // recordCommissionFailure 在单条佣金回扣事务回滚后记录失败事实。 func (s *Service) recordCommissionFailure(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord, businessErr error) { if businessErr == nil || refund == nil || commission == nil || s.auditWriter == nil || s.db == nil { return } primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) primary.SubjectVisibility = constants.AuditSubjectInternalOnly commissionResource := audit.CommissionRecordResource(commission, map[string]any{"status": commission.Status}, nil) commissionResource.SubjectVisibility = constants.AuditSubjectInternalOnly s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ ActionCode: constants.AuditActionRefundCommissionInvalidated, Summary: "退款失效佣金失败", ScopeType: constants.AuditScopePlatform, CorrelationID: refund.RefundNo, Resources: []audit.ResourceInput{primary, commissionResource}, }, businessErr) } func refundAuditState(refund *model.RefundRequest) map[string]any { return map[string]any{ "status": refund.Status, "approved_refund_amount": refund.ApprovedRefundAmount, "approval_instance_id": refund.ApprovalInstanceID, "processor_id": refund.ProcessorID, "processed_at": refund.ProcessedAt, "commission_deducted": refund.CommissionDeducted, "asset_reset": refund.AssetReset, "reject_reason": refund.RejectReason, "remark": refund.Remark, } } // markRefundAssetProcessed 仅在首次完成后处理时同事务写入完成标记和审计事件。 func (s *Service) markRefundAssetProcessed(ctx context.Context, refundID uint) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND asset_reset = ?", refundID, false).Update("asset_reset", true) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款资产处理标记失败") } if result.RowsAffected == 0 { return nil } return s.appendRefundAudit(ctx, tx, refundID, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理", "refund:"+strconv.FormatUint(uint64(refundID), 10)+":asset-processed", map[string]any{"asset_reset": false}, nil, "退款资产处理已完成") }) }