package mysql import ( "context" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) // ApplyGameCoinChange 在同一事务内完成游戏 COIN 改账、分录和 outbox。 func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) { if r == nil || r.db == nil { return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) bizType, delta, err := gameBizTypeAndDelta(command.OpType, command.CoinAmount) if err != nil { return ledger.GameCoinChangeReceipt{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.GameCoinChangeReceipt{}, err } defer func() { _ = tx.Rollback() }() if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, command.RequestHash, bizType, xerr.IdempotencyConflict); err != nil || exists { if err != nil || !exists { return ledger.GameCoinChangeReceipt{}, err } receipt, receiptErr := r.receiptForGameTransaction(ctx, tx, txRow.TransactionID) receipt.IdempotentReplay = true return receipt, receiptErr } nowMs := time.Now().UnixMilli() createIfMissing := delta > 0 account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, createIfMissing, nowMs) if err != nil { return ledger.GameCoinChangeReceipt{}, err } if delta < 0 && account.AvailableAmount < -delta { return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } transactionID := transactionID(command.AppCode, command.CommandID) balanceAfter := account.AvailableAmount + delta metadata := gameCoinMetadata{ AppCode: command.AppCode, UserID: command.UserID, PlatformCode: command.PlatformCode, GameID: command.GameID, ProviderOrderID: command.ProviderOrderID, ProviderRoundID: command.ProviderRoundID, OpType: command.OpType, AssetType: ledger.AssetCoin, CoinAmount: command.CoinAmount, AvailableDelta: delta, RoomID: command.RoomID, BalanceAfter: balanceAfter, AppliedAtMS: nowMs, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, command.RequestHash, command.ProviderOrderID, metadata, nowMs); err != nil { return ledger.GameCoinChangeReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, account, delta, 0, nowMs); err != nil { return ledger.GameCoinChangeReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.UserID, AssetType: ledger.AssetCoin, AvailableDelta: delta, FrozenDelta: 0, AvailableAfter: balanceAfter, FrozenAfter: account.FrozenAmount, RoomID: command.RoomID, CreatedAtMS: nowMs, }); err != nil { return ledger.GameCoinChangeReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs, bizType), }); err != nil { return ledger.GameCoinChangeReceipt{}, err } if err := tx.Commit(); err != nil { return ledger.GameCoinChangeReceipt{}, err } return receiptFromGameMetadata(transactionID, metadata, false), nil } func gameBizTypeAndDelta(opType string, coinAmount int64) (string, int64, error) { switch ledger.NormalizeGameOpType(opType) { case ledger.GameOpDebit: return bizTypeGameDebit, -coinAmount, nil case ledger.GameOpCredit: return bizTypeGameCredit, coinAmount, nil case ledger.GameOpRefund: return bizTypeGameRefund, coinAmount, nil case ledger.GameOpReverse: return bizTypeGameReverse, -coinAmount, nil default: return "", 0, xerr.New(xerr.InvalidArgument, "game op_type is invalid") } }