// Package wallet 提供代理主钱包复杂写用例。 package wallet import ( "context" "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" ) // DebitCommand 描述一次具有稳定业务引用的代理主钱包扣款。 type DebitCommand struct { ShopID uint Amount int64 ReferenceType string ReferenceID uint UserID uint Creator uint TransactionSubtype string RelatedShopID *uint AssetType string AssetID uint AssetIdentifier string Remark string RequestID string CorrelationID string } // DebitResult 返回统一扣款后的资金快照。 type DebitResult struct { WalletID uint BalanceBefore int64 BalanceAfter int64 Version int AlreadyApplied bool } // DebitedEvent 是代理主钱包扣款成功后的可靠领域事实。 type DebitedEvent struct { EventID string `json:"event_id"` WalletID uint `json:"wallet_id"` ShopID uint `json:"shop_id"` Amount int64 `json:"amount"` BalanceBefore int64 `json:"balance_before"` BalanceAfter int64 `json:"balance_after"` Version int `json:"version"` ReferenceType string `json:"reference_type"` ReferenceID uint `json:"reference_id"` TransactionType string `json:"transaction_type"` OccurredAt time.Time `json:"occurred_at"` RequestID string `json:"request_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"` } // DebitEventWriter 在调用方事务内追加扣款成功事件。 type DebitEventWriter interface { Append(ctx context.Context, tx *gorm.DB, event DebitedEvent) error } // DebitService 统一代理主钱包扣款、流水与可靠事件。 type DebitService struct { eventWriter DebitEventWriter now func() time.Time } // NewDebitService 创建统一代理主钱包扣款服务。 func NewDebitService(eventWriter DebitEventWriter, now func() time.Time) *DebitService { if now == nil { now = time.Now } return &DebitService{eventWriter: eventWriter, now: now} } // DebitInTx 在调用方事务内完成锁定、扣款、唯一流水和 Outbox 事件。 func (s *DebitService) DebitInTx(ctx context.Context, tx *gorm.DB, command DebitCommand) (DebitResult, error) { if s == nil || s.eventWriter == nil || tx == nil { return DebitResult{}, errors.New(errors.CodeInternalError, "代理主钱包扣款能力未完整配置") } if err := validateDebitCommand(command); err != nil { return DebitResult{}, err } var stored model.AgentWallet if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain). First(&stored).Error; err != nil { if err == gorm.ErrRecordNotFound { return DebitResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在") } return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败") } existing, err := findExistingDebit(ctx, tx, command.ReferenceType, command.ReferenceID) if err != nil { return DebitResult{}, err } if existing != nil { if existing.AgentWalletID != stored.ID || existing.Amount != -command.Amount { return DebitResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包扣款流水") } return DebitResult{ 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.Debit(command.Amount); err != nil { return DebitResult{}, err } updatedAt := s.now().UTC() update := tx.WithContext(ctx).Model(&model.AgentWallet{}). Where(`id = ? AND wallet_type = ? AND status = ? AND version = ? AND balance::numeric - frozen_balance::numeric + CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0 END >= ?::numeric`, stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version, command.Amount). Updates(map[string]any{ "balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": updatedAt, }) if update.Error != nil { return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "扣减代理主钱包失败") } if update.RowsAffected != 1 { return DebitResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试") } transaction := buildDebitTransaction(stored, aggregate.Balance, command) if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包扣款流水失败") } event := DebitedEvent{ EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited", WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1, ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID, TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: updatedAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID, } if err := s.eventWriter.Append(ctx, tx, event); err != nil { return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包扣款事件失败") } return DebitResult{ WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1, }, nil } func validateDebitCommand(command DebitCommand) error { if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 { return errors.New(errors.CodeInvalidParam, "代理主钱包扣款参数无效") } if strings.TrimSpace(command.ReferenceType) != constants.ReferenceTypeOrder { return errors.New(errors.CodeInvalidParam, "当前统一扣款仅支持订单业务") } return nil } func findExistingDebit(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) { var transaction model.AgentWalletTransaction err := tx.WithContext(ctx).Unscoped(). Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?", referenceType, referenceID, constants.AgentTransactionTypeDeduct, 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 buildDebitTransaction(stored model.AgentWallet, balanceAfter int64, command DebitCommand) *model.AgentWalletTransaction { referenceType := strings.TrimSpace(command.ReferenceType) remark := strings.TrimSpace(command.Remark) var subtype *string if value := strings.TrimSpace(command.TransactionSubtype); value != "" { subtype = &value } return &model.AgentWalletTransaction{ AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID, TransactionType: constants.AgentTransactionTypeDeduct, TransactionSubtype: subtype, Amount: -command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter, Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID, RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID, AssetIdentifier: command.AssetIdentifier, Remark: &remark, Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag, } }