package wallet import ( "context" "math" "strconv" "strings" "time" domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "gorm.io/gorm" "gorm.io/gorm/clause" ) // RefundCommand 描述一次沿订单原扣款回溯的代理主钱包退款。 type RefundCommand struct { OrderID uint RefundID uint Amount int64 LegacyPayerShopID uint LegacyDeductAmount int64 LegacyRelatedShopID *uint AssetType string AssetID uint AssetIdentifier string UserID uint Creator uint Remark string RequestID string CorrelationID string } // RefundResult 返回代理主钱包退款后的资金快照。 type RefundResult struct { WalletID uint BalanceBefore int64 BalanceAfter int64 Version int AlreadyApplied bool } // RefundedEvent 是代理主钱包订单退款成功后的可靠资金事实。 type RefundedEvent struct { EventID string `json:"event_id"` WalletID uint `json:"wallet_id"` ShopID uint `json:"shop_id"` OrderID uint `json:"order_id"` RefundID uint `json:"refund_id"` Amount int64 `json:"amount"` BalanceBefore int64 `json:"balance_before"` BalanceAfter int64 `json:"balance_after"` Version int `json:"version"` OriginalDebitTransactionID uint `json:"original_debit_transaction_id,omitempty"` OriginalDeductAmount int64 `json:"original_deduct_amount"` RelatedShopID *uint `json:"related_shop_id,omitempty"` TransactionSubtype *string `json:"transaction_subtype,omitempty"` AssetType string `json:"asset_type,omitempty"` AssetID uint `json:"asset_id,omitempty"` AssetIdentifier string `json:"asset_identifier,omitempty"` Legacy bool `json:"legacy"` OccurredAt time.Time `json:"occurred_at"` RequestID string `json:"request_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"` } // RefundEventWriter 在调用方事务内追加代理主钱包退款事件。 type RefundEventWriter interface { Append(ctx context.Context, tx *gorm.DB, event RefundedEvent) error } // RefundService 统一代理订单退款回充、真实流水和可靠事件。 type RefundService struct { eventWriter RefundEventWriter now func() time.Time } // NewRefundService 创建统一代理主钱包退款服务。 func NewRefundService(eventWriter RefundEventWriter, now func() time.Time) *RefundService { if now == nil { now = time.Now } return &RefundService{eventWriter: eventWriter, now: now} } // RefundInTx 在调用方事务内按原扣款事实完成退款回充、版本递增、唯一流水和 Outbox。 func (s *RefundService) RefundInTx(ctx context.Context, tx *gorm.DB, command RefundCommand) (RefundResult, error) { if s == nil || s.eventWriter == nil || tx == nil { return RefundResult{}, errors.New(errors.CodeInternalError, "代理主钱包退款能力未完整配置") } if err := validateRefundCommand(command); err != nil { return RefundResult{}, err } origin, err := findOriginalOrderDebit(ctx, tx, command.OrderID) if err != nil { return RefundResult{}, err } resolution, err := resolveRefundTarget(command, origin) if err != nil { return RefundResult{}, err } stored, err := lockRefundWallet(ctx, tx, resolution) if err != nil { return RefundResult{}, err } existing, err := findExistingRefund(ctx, tx, command.RefundID) if err != nil { return RefundResult{}, err } if existing != nil { if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount || !sameOptionalUint(existing.RelatedShopID, resolution.relatedShopID) || !sameOptionalString(existing.TransactionSubtype, resolution.subtype) || existing.AssetType != resolution.assetType || existing.AssetID != resolution.assetID || existing.AssetIdentifier != resolution.assetIdentifier { return RefundResult{}, errors.New(errors.CodeConflict, "退款单已存在不一致的钱包回充流水") } return RefundResult{ WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter, Version: stored.Version, AlreadyApplied: true, }, nil } aggregate := domainwallet.AgentWallet{ ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version, } if err := aggregate.Credit(command.Amount); err != nil { return RefundResult{}, err } now := s.now().UTC() update := tx.WithContext(ctx).Model(&model.AgentWallet{}). Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version). Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now}) if update.Error != nil { return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "退回代理主钱包余额失败") } if update.RowsAffected != 1 { return RefundResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试") } transaction := buildRefundTransaction(stored, aggregate.Balance, command, resolution) if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包退款流水失败") } event := RefundedEvent{ EventID: "agent-wallet:refund:" + strconv.FormatUint(uint64(command.RefundID), 10) + ":refunded", WalletID: stored.ID, ShopID: stored.ShopID, OrderID: command.OrderID, RefundID: command.RefundID, Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1, OriginalDebitTransactionID: resolution.originalDebitID, OriginalDeductAmount: resolution.deductAmount, RelatedShopID: resolution.relatedShopID, TransactionSubtype: resolution.subtype, Legacy: resolution.legacy, AssetType: resolution.assetType, AssetID: resolution.assetID, AssetIdentifier: resolution.assetIdentifier, OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID, } if err := s.eventWriter.Append(ctx, tx, event); err != nil { return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包退款事件失败") } return RefundResult{ WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1, }, nil } type refundResolution struct { walletID uint shopID uint originalDebitID uint deductAmount int64 relatedShopID *uint subtype *string assetType string assetID uint assetIdentifier string legacy bool } func validateRefundCommand(command RefundCommand) error { if command.OrderID == 0 || command.RefundID == 0 || command.Amount <= 0 { return errors.New(errors.CodeInvalidParam, "代理主钱包退款参数无效") } return nil } func findOriginalOrderDebit(ctx context.Context, tx *gorm.DB, orderID uint) (*model.AgentWalletTransaction, error) { var transaction model.AgentWalletTransaction err := tx.WithContext(ctx).Unscoped(). Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?", constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess). Order("id ASC").First(&transaction).Error if err == nil { return &transaction, nil } if err == gorm.ErrRecordNotFound { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理主钱包扣款流水失败") } func resolveRefundTarget(command RefundCommand, origin *model.AgentWalletTransaction) (refundResolution, error) { if origin != nil { if origin.Amount >= 0 || origin.Amount == math.MinInt64 { return refundResolution{}, errors.New(errors.CodeInternalError, "原代理主钱包扣款流水金额非法") } maxAmount := -origin.Amount if command.Amount > maxAmount { return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于原钱包扣款金额") } return refundResolution{ walletID: origin.AgentWalletID, originalDebitID: origin.ID, deductAmount: maxAmount, relatedShopID: origin.RelatedShopID, subtype: origin.TransactionSubtype, assetType: origin.AssetType, assetID: origin.AssetID, assetIdentifier: origin.AssetIdentifier, }, nil } if command.LegacyPayerShopID == 0 || command.LegacyDeductAmount <= 0 { return refundResolution{}, errors.New(errors.CodeInternalError, "历史订单缺少原扣款流水和兼容付款快照") } if command.Amount > command.LegacyDeductAmount { return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于历史订单实付金额") } return refundResolution{ shopID: command.LegacyPayerShopID, relatedShopID: command.LegacyRelatedShopID, deductAmount: command.LegacyDeductAmount, assetType: command.AssetType, assetID: command.AssetID, assetIdentifier: command.AssetIdentifier, legacy: true, }, nil } func lockRefundWallet(ctx context.Context, tx *gorm.DB, resolution refundResolution) (model.AgentWallet, error) { var stored model.AgentWallet query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("wallet_type = ?", constants.AgentWalletTypeMain) if resolution.walletID > 0 { query = query.Where("id = ?", resolution.walletID) } else { query = query.Where("shop_id = ?", resolution.shopID) } if err := query.First(&stored).Error; err != nil { if err == gorm.ErrRecordNotFound { return model.AgentWallet{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在") } return model.AgentWallet{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败") } return stored, nil } func findExistingRefund(ctx context.Context, tx *gorm.DB, refundID uint) (*model.AgentWalletTransaction, error) { var transaction model.AgentWalletTransaction err := tx.WithContext(ctx).Unscoped(). Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?", constants.ReferenceTypeRefund, refundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess). First(&transaction).Error if err == nil { return &transaction, nil } if err == gorm.ErrRecordNotFound { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败") } func buildRefundTransaction(stored model.AgentWallet, balanceAfter int64, command RefundCommand, resolution refundResolution) *model.AgentWalletTransaction { referenceType := constants.ReferenceTypeRefund remark := strings.TrimSpace(command.Remark) return &model.AgentWalletTransaction{ AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID, TransactionType: constants.AgentTransactionTypeRefund, TransactionSubtype: resolution.subtype, Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter, Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.RefundID, RelatedShopID: resolution.relatedShopID, AssetType: resolution.assetType, AssetID: resolution.assetID, AssetIdentifier: resolution.assetIdentifier, Remark: &remark, Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag, } } func sameOptionalUint(left, right *uint) bool { return (left == nil && right == nil) || (left != nil && right != nil && *left == *right) } func sameOptionalString(left, right *string) bool { return (left == nil && right == nil) || (left != nil && right != nil && *left == *right) }