package wallet import ( "context" "strconv" "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" ) // ReservationCommand 描述一次具有稳定业务引用的代理主钱包预占操作。 type ReservationCommand struct { ShopID uint Amount int64 ReferenceType string ReferenceID uint UserID uint Creator uint Subtype string RelatedShopID *uint AssetType string AssetID uint AssetIdentifier string Remark string RequestID string CorrelationID string } // ReservationEvent 是代理主钱包预占状态变化事件。 type ReservationEvent struct { EventID string `json:"event_id"` ReservationID uint `json:"reservation_id"` WalletID uint `json:"wallet_id"` ShopID uint `json:"shop_id"` Amount int64 `json:"amount"` Status int `json:"status"` ReferenceType string `json:"reference_type"` ReferenceID uint `json:"reference_id"` OccurredAt time.Time `json:"occurred_at"` RequestID string `json:"request_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"` } // ReservationEventWriter 在业务事务内追加预占状态事件。 type ReservationEventWriter interface { Append(ctx context.Context, tx *gorm.DB, event ReservationEvent) error } // ReservationService 统一代理主钱包冻结、释放与完成扣除。 type ReservationService struct { reservationEvents ReservationEventWriter debitEvents DebitEventWriter now func() time.Time } // NewReservationService 创建代理主钱包预占服务。 func NewReservationService(reservationEvents ReservationEventWriter, debitEvents DebitEventWriter, now func() time.Time) *ReservationService { if now == nil { now = time.Now } return &ReservationService{reservationEvents: reservationEvents, debitEvents: debitEvents, now: now} } // FreezeInTx 在调用方事务内冻结资金并创建唯一预占事实。 func (s *ReservationService) FreezeInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error { if err := s.validateFreeze(tx, command); err != nil { return err } wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID) if err != nil { return err } existing, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, false) if err != nil { return err } if existing != nil { if existing.AgentWalletID == wallet.ID && existing.Amount == command.Amount && existing.Status == constants.AgentWalletReservationStatusFrozen { return nil } return errors.New(errors.CodeConflict, "业务引用已存在不一致的钱包预占") } if err := aggregate.Freeze(command.Amount); err != nil { return err } now := s.now().UTC() if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil { return err } reservation := &model.AgentWalletReservation{ AgentWalletID: wallet.ID, ShopID: wallet.ShopID, Amount: command.Amount, Status: constants.AgentWalletReservationStatusFrozen, ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID, Creator: command.Creator, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, } if err := tx.WithContext(ctx).Create(reservation).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包预占事实失败") } return s.appendReservationEvent(ctx, tx, reservation, now, command) } // ReleaseInTx 幂等释放处于冻结状态的资金预占。 func (s *ReservationService) ReleaseInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error { return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusReleased) } // CompleteInTx 完成冻结资金扣除并创建真实扣款流水与扣款事件。 func (s *ReservationService) CompleteInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error { return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusCompleted) } func (s *ReservationService) finish(ctx context.Context, tx *gorm.DB, command ReservationCommand, targetStatus int) error { if err := s.validateFinish(tx, command); err != nil { return err } reservation, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, true) if err != nil { return err } if reservation == nil { return errors.New(errors.CodeNotFound, "代理主钱包预占事实不存在") } if (command.ShopID > 0 && reservation.ShopID != command.ShopID) || (command.Amount > 0 && reservation.Amount != command.Amount) { return errors.New(errors.CodeConflict, "钱包预占事实与请求不一致") } command.ShopID = reservation.ShopID command.Amount = reservation.Amount if reservation.Status == targetStatus { return nil } if reservation.Status != constants.AgentWalletReservationStatusFrozen { return errors.New(errors.CodeInvalidStatus, "钱包预占已经进入其他终态") } wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID) if err != nil { return err } if wallet.ID != reservation.AgentWalletID { return errors.New(errors.CodeConflict, "钱包预占归属不一致") } if targetStatus == constants.AgentWalletReservationStatusReleased { err = aggregate.Release(command.Amount) } else { err = aggregate.CompleteReserved(command.Amount) } if err != nil { return err } now := s.now().UTC() if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil { return err } result := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}). Where("id = ? AND status = ?", reservation.ID, constants.AgentWalletReservationStatusFrozen). Updates(map[string]any{"status": targetStatus, "completed_at": now, "updated_at": now}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占状态失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "钱包预占状态已变化") } reservation.Status = targetStatus reservation.CompletedAt = &now if targetStatus == constants.AgentWalletReservationStatusCompleted { if err := s.createCompletedDebit(ctx, tx, wallet, aggregate, command, now); err != nil { return err } } return s.appendReservationEvent(ctx, tx, reservation, now, command) } func (s *ReservationService) validateConfigured(tx *gorm.DB) error { if s == nil || s.reservationEvents == nil || s.debitEvents == nil || tx == nil { return errors.New(errors.CodeInternalError, "代理主钱包预占能力未完整配置") } return nil } func (s *ReservationService) validateFreeze(tx *gorm.DB, command ReservationCommand) error { if err := s.validateConfigured(tx); err != nil { return err } if command.ShopID == 0 || command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount <= 0 { return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效") } return nil } func (s *ReservationService) validateFinish(tx *gorm.DB, command ReservationCommand) error { if err := s.validateConfigured(tx); err != nil { return err } if command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount < 0 { return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效") } return nil } func lockMainWallet(ctx context.Context, tx *gorm.DB, shopID uint) (model.AgentWallet, *domainwallet.AgentWallet, error) { var wallet model.AgentWallet if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err != nil { if err == gorm.ErrRecordNotFound { return wallet, nil, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在") } return wallet, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败") } aggregate := &domainwallet.AgentWallet{ ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType, Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance, CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit, Status: wallet.Status, Version: wallet.Version, } return wallet, aggregate, nil } func findReservation(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint, lock bool) (*model.AgentWalletReservation, error) { var reservation model.AgentWalletReservation query := tx.WithContext(ctx) if lock { query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } err := query.Where("reference_type = ? AND reference_id = ?", referenceType, referenceID).First(&reservation).Error if err == nil { return &reservation, nil } if err == gorm.ErrRecordNotFound { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败") } func updateWalletFunds(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, now time.Time) error { result := 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, "frozen_balance": aggregate.FrozenBalance, "version": gorm.Expr("version + 1"), "updated_at": now, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占资金失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "钱包版本已变化,请重试") } return nil } func (s *ReservationService) createCompletedDebit(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, command ReservationCommand, now time.Time) error { referenceType := command.ReferenceType transaction := &model.AgentWalletTransaction{ AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID, TransactionType: constants.AgentTransactionTypeDeduct, Amount: -command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID, RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID, AssetIdentifier: command.AssetIdentifier, Remark: &command.Remark, Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag, } if command.Subtype != "" { transaction.TransactionSubtype = &command.Subtype } if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { return 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: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID, } if err := s.debitEvents.Append(ctx, tx, event); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入冻结资金扣款事件失败") } return nil } func (s *ReservationService) appendReservationEvent(ctx context.Context, tx *gorm.DB, reservation *model.AgentWalletReservation, now time.Time, command ReservationCommand) error { event := ReservationEvent{ EventID: "agent-wallet-reservation:" + strconv.FormatUint(uint64(reservation.ID), 10) + ":" + strconv.Itoa(reservation.Status), ReservationID: reservation.ID, WalletID: reservation.AgentWalletID, ShopID: reservation.ShopID, Amount: reservation.Amount, Status: reservation.Status, ReferenceType: reservation.ReferenceType, ReferenceID: reservation.ReferenceID, OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID, } if err := s.reservationEvents.Append(ctx, tx, event); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包预占事件失败") } return nil }