package mysql import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) // AdminCreditCoinSellerStock 在同一事务里调整币商 COIN_SELLER_COIN 库存,并记录专用进货/冲回明细。 func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) { if r == nil || r.db == nil { return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } defer func() { _ = tx.Rollback() }() bizType := coinSellerStockBizType(command.StockType) if bizType == "" { return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid") } requestHash := coinSellerStockRequestHash(command) if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizType, xerr.IdempotencyConflict); err != nil || exists { if err != nil || !exists { return ledger.CoinSellerStockCreditReceipt{}, err } return r.receiptForCoinSellerStockTransaction(ctx, tx, txRow.TransactionID) } if command.PaymentRef != "" { if duplicated, err := r.coinSellerStockPaymentRefExists(ctx, tx, command.StockType, command.PaymentRef); err != nil || duplicated { if err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated") } } nowMs := time.Now().UnixMilli() availableDelta := coinSellerStockAvailableDelta(command) account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, availableDelta > 0, nowMs) if err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } if availableDelta < 0 && account.AvailableAmount < command.CoinAmount { return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } balanceAfter, err := checkedAddDelta(account.AvailableAmount, availableDelta) if err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } transactionID := transactionID(command.AppCode, command.CommandID) countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase || command.StockType == ledger.StockTypeUSDTDeduction recordCoinAmount := availableDelta recordPaidAmountMicro := coinSellerStockPaidAmountMicro(command) metadata := coinSellerStockMetadata{ AppCode: command.AppCode, SellerUserID: command.SellerUserID, SellerCountryID: command.SellerCountryID, SellerRegionID: command.SellerRegionID, StockType: command.StockType, CoinAmount: recordCoinAmount, PaidCurrencyCode: command.PaidCurrencyCode, PaidAmountMicro: recordPaidAmountMicro, PaymentRef: command.PaymentRef, EvidenceRef: command.EvidenceRef, OperatorUserID: command.OperatorUserID, Reason: command.Reason, AssetType: ledger.AssetCoinSellerCoin, CountsAsSellerRecharge: countsAsSellerRecharge, BalanceAfter: balanceAfter, CreatedAtMS: nowMs, } externalRef := command.EvidenceRef if command.PaymentRef != "" { externalRef = command.PaymentRef } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, account, availableDelta, 0, nowMs); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, AvailableDelta: availableDelta, FrozenDelta: 0, AvailableAfter: balanceAfter, FrozenAfter: account.FrozenAmount, CreatedAtMS: nowMs, }); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, command.CommandID, metadata, nowMs); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, availableDelta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs), }); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } if err := tx.Commit(); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err } return receiptFromCoinSellerStockMetadata(transactionID, metadata), nil } // TransferCoinFromSeller 在同一事务里扣币商专用金币、给玩家普通 COIN 入账,并记录充值口径。 func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) { if r == nil || r.db == nil { return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.CoinSellerTransferReceipt{}, err } defer func() { _ = tx.Rollback() }() requestHash := coinSellerTransferRequestHash(command) if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCoinSellerTransfer); err != nil || exists { if err != nil || !exists { return ledger.CoinSellerTransferReceipt{}, err } return r.receiptForCoinSellerTransferTransaction(ctx, tx, txRow.TransactionID) } nowMs := time.Now().UnixMilli() seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, false, nowMs) if err != nil { return ledger.CoinSellerTransferReceipt{}, err } if seller.AvailableAmount < command.Amount { return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs) if err != nil { return ledger.CoinSellerTransferReceipt{}, err } transactionID := transactionID(command.AppCode, command.CommandID) rechargeUSDMinor := int64(0) rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.TargetUserID, transactionID, command.Amount, rechargeUSDMinor, nowMs) if err != nil { return ledger.CoinSellerTransferReceipt{}, err } sellerAfter := seller.AvailableAmount - command.Amount targetAfter := target.AvailableAmount + command.Amount metadata := coinSellerTransferMetadata{ AppCode: command.AppCode, SellerUserID: command.SellerUserID, TargetUserID: command.TargetUserID, TargetCountryID: command.TargetCountryID, SellerRegionID: command.SellerRegionID, TargetRegionID: command.TargetRegionID, Amount: command.Amount, Reason: command.Reason, SellerAssetType: ledger.AssetCoinSellerCoin, TargetAssetType: ledger.AssetCoin, SellerBalanceAfter: sellerAfter, TargetBalanceAfter: targetAfter, RechargeSequence: rechargeSequence, RechargeUSDMinor: rechargeUSDMinor, RechargeCurrencyCode: "", RechargePolicyID: 0, RechargePolicyVersion: "", RechargePolicyCoinAmount: 0, RechargePolicyUSDMinorAmount: 0, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCoinSellerTransfer, requestHash, fmt.Sprintf("coin_seller:%d:%d", command.SellerUserID, command.TargetUserID), metadata, nowMs); err != nil { return ledger.CoinSellerTransferReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, seller, -command.Amount, 0, nowMs); err != nil { return ledger.CoinSellerTransferReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, AvailableDelta: -command.Amount, FrozenDelta: 0, AvailableAfter: sellerAfter, FrozenAfter: seller.FrozenAmount, CounterpartyUserID: command.TargetUserID, CreatedAtMS: nowMs, }); err != nil { return ledger.CoinSellerTransferReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, target, command.Amount, 0, nowMs); err != nil { return ledger.CoinSellerTransferReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.TargetUserID, AssetType: ledger.AssetCoin, AvailableDelta: command.Amount, FrozenDelta: 0, AvailableAfter: targetAfter, FrozenAfter: target.FrozenAmount, CounterpartyUserID: command.SellerUserID, CreatedAtMS: nowMs, }); err != nil { return ledger.CoinSellerTransferReceipt{}, err } if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMs); err != nil { return ledger.CoinSellerTransferReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, -command.Amount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs), balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, target.Version+1, metadata, nowMs), coinSellerTransferredEvent(transactionID, command.CommandID, command.SellerUserID, -command.Amount, metadata, nowMs), rechargeRecordedEvent(transactionID, command.CommandID, command.TargetUserID, command.Amount, metadata, nowMs), }); err != nil { return ledger.CoinSellerTransferReceipt{}, err } if err := tx.Commit(); err != nil { return ledger.CoinSellerTransferReceipt{}, err } return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil } // ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间;默认只返回 active 区间。 func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, appCode) statusClause := "AND status = 'active'" if includeDisabled { statusClause = "" } query := fmt.Sprintf(` SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms FROM coin_seller_salary_exchange_rate_tiers WHERE app_code = ? AND region_id = ? %s ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC`, statusClause) rows, err := r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID) if err != nil { return nil, err } defer rows.Close() tiers := make([]ledger.CoinSellerSalaryExchangeRateTier, 0) for rows.Next() { var tier ledger.CoinSellerSalaryExchangeRateTier if err := rows.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil { return nil, err } tiers = append(tiers, tier) } if err := rows.Err(); err != nil { return nil, err } return tiers, nil } // ExchangeSalaryToCoin 在同一事务里扣工资美元钱包、给当前用户普通 COIN 入账,保证两边余额和幂等一起落库。 func (r *Repository) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) { if r == nil || r.db == nil { return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.SalaryExchangeReceipt{}, err } defer func() { _ = tx.Rollback() }() requestHash := salaryExchangeRequestHash(command) if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryExchangeToCoin, xerr.IdempotencyConflict); err != nil || exists { if err != nil || !exists { return ledger.SalaryExchangeReceipt{}, err } return r.receiptForSalaryExchangeTransaction(ctx, tx, txRow.TransactionID) } nowMs := time.Now().UnixMilli() coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, ledger.SalaryExchangeCoinPerUSD) if err != nil { return ledger.SalaryExchangeReceipt{}, err } salaryAccount, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs) if err != nil { return ledger.SalaryExchangeReceipt{}, err } if salaryAccount.AvailableAmount < command.SalaryUSDMinor { return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } coinAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs) if err != nil { return ledger.SalaryExchangeReceipt{}, err } transactionID := transactionID(command.AppCode, command.CommandID) salaryAfter := salaryAccount.AvailableAmount - command.SalaryUSDMinor coinAfter, err := checkedAdd(coinAccount.AvailableAmount, coinAmount) if err != nil { return ledger.SalaryExchangeReceipt{}, err } metadata := salaryExchangeMetadata{ AppCode: command.AppCode, UserID: command.UserID, SalaryAssetType: command.SalaryAssetType, CoinAssetType: ledger.AssetCoin, SalaryUSDMinor: command.SalaryUSDMinor, CoinPerUSD: ledger.SalaryExchangeCoinPerUSD, CoinAmount: coinAmount, SalaryBalanceAfter: salaryAfter, CoinBalanceAfter: coinAfter, Reason: command.Reason, CreatedAtMS: nowMs, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryExchangeToCoin, requestHash, fmt.Sprintf("salary_exchange:%d", command.UserID), metadata, nowMs); err != nil { return ledger.SalaryExchangeReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, salaryAccount, -command.SalaryUSDMinor, 0, nowMs); err != nil { return ledger.SalaryExchangeReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.UserID, AssetType: command.SalaryAssetType, AvailableDelta: -command.SalaryUSDMinor, FrozenDelta: 0, AvailableAfter: salaryAfter, FrozenAfter: salaryAccount.FrozenAmount, CreatedAtMS: nowMs, }); err != nil { return ledger.SalaryExchangeReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, coinAccount, coinAmount, 0, nowMs); err != nil { return ledger.SalaryExchangeReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.UserID, AssetType: ledger.AssetCoin, AvailableDelta: coinAmount, FrozenDelta: 0, AvailableAfter: coinAfter, FrozenAfter: coinAccount.FrozenAmount, CreatedAtMS: nowMs, }); err != nil { return ledger.SalaryExchangeReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.UserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, salaryAfter, salaryAccount.FrozenAmount, salaryAccount.Version+1, metadata, nowMs), balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, coinAmount, 0, coinAfter, coinAccount.FrozenAmount, coinAccount.Version+1, metadata, nowMs), }); err != nil { return ledger.SalaryExchangeReceipt{}, err } if err := tx.Commit(); err != nil { return ledger.SalaryExchangeReceipt{}, err } return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil } // TransferSalaryToCoinSeller 在同一事务里扣来源工资钱包、按当前区域配置给币商专用金币库存入账。 func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) { if r == nil || r.db == nil { return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } defer func() { _ = tx.Rollback() }() requestHash := salaryTransferToCoinSellerRequestHash(command) if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryTransferToCoinSeller, xerr.IdempotencyConflict); err != nil || exists { if err != nil || !exists { return ledger.SalaryTransferToCoinSellerReceipt{}, err } return r.receiptForSalaryTransferToCoinSellerTransaction(ctx, tx, txRow.TransactionID) } nowMs := time.Now().UnixMilli() rate, err := r.resolveCoinSellerSalaryExchangeRateTier(ctx, tx, command.RegionID, command.SalaryUSDMinor) if err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, rate.CoinPerUSD) if err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } source, err := r.lockAccount(ctx, tx, command.SourceUserID, command.SalaryAssetType, false, nowMs) if err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } if source.AvailableAmount < command.SalaryUSDMinor { return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs) if err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } transactionID := transactionID(command.AppCode, command.CommandID) sourceAfter := source.AvailableAmount - command.SalaryUSDMinor sellerAfter, err := checkedAdd(seller.AvailableAmount, coinAmount) if err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } metadata := salaryTransferToCoinSellerMetadata{ AppCode: command.AppCode, SourceUserID: command.SourceUserID, SellerUserID: command.SellerUserID, RegionID: command.RegionID, SalaryAssetType: command.SalaryAssetType, SellerAssetType: ledger.AssetCoinSellerCoin, SalaryUSDMinor: command.SalaryUSDMinor, CoinPerUSD: rate.CoinPerUSD, CoinAmount: coinAmount, RateMinUSDMinor: rate.MinUSDMinor, RateMaxUSDMinor: rate.MaxUSDMinor, SourceSalaryBalanceAfter: sourceAfter, SellerBalanceAfter: sellerAfter, Reason: command.Reason, CreatedAtMS: nowMs, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryTransferToCoinSeller, requestHash, fmt.Sprintf("salary_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMs); err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, source, -command.SalaryUSDMinor, 0, nowMs); err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.SourceUserID, AssetType: command.SalaryAssetType, AvailableDelta: -command.SalaryUSDMinor, FrozenDelta: 0, AvailableAfter: sourceAfter, FrozenAfter: source.FrozenAmount, CounterpartyUserID: command.SellerUserID, CreatedAtMS: nowMs, }); err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, seller, coinAmount, 0, nowMs); err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, AvailableDelta: coinAmount, FrozenDelta: 0, AvailableAfter: sellerAfter, FrozenAfter: seller.FrozenAmount, CounterpartyUserID: command.SourceUserID, CreatedAtMS: nowMs, }); err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs, bizTypeSalaryTransferToCoinSeller), balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs, bizTypeSalaryTransferToCoinSeller), }); err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } if err := tx.Commit(); err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil } func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, transactionID string, metadata coinSellerTransferMetadata, nowMs int64) error { _, err := tx.ExecContext(ctx, `INSERT INTO wallet_recharge_records ( app_code, transaction_id, user_id, recharge_sequence, seller_user_id, seller_region_id, target_region_id, policy_id, policy_version, currency_code, coin_amount, usd_minor_amount, exchange_coin_amount, exchange_usd_minor_amount, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, normalizedEntryAppCode(ctx, metadata.AppCode), transactionID, metadata.TargetUserID, metadata.RechargeSequence, metadata.SellerUserID, metadata.SellerRegionID, metadata.TargetRegionID, metadata.RechargePolicyID, metadata.RechargePolicyVersion, metadata.RechargeCurrencyCode, metadata.Amount, metadata.RechargeUSDMinor, metadata.Amount, metadata.RechargePolicyUSDMinorAmount, nowMs, ) return err } func (r *Repository) reserveUserRechargeSequence(ctx context.Context, tx *sql.Tx, appCode string, userID int64, transactionID string, coinAmount int64, usdMinorAmount int64, nowMs int64) (int64, error) { normalizedApp := normalizedEntryAppCode(ctx, appCode) var count int64 err := tx.QueryRowContext(ctx, ` SELECT recharge_count FROM wallet_user_recharge_stats WHERE app_code = ? AND user_id = ? FOR UPDATE`, normalizedApp, userID, ).Scan(&count) if errors.Is(err, sql.ErrNoRows) { _, err = tx.ExecContext(ctx, ` INSERT INTO wallet_user_recharge_stats ( app_code, user_id, recharge_count, first_transaction_id, first_recharged_at_ms, total_coin_amount, total_usd_minor_amount, created_at_ms, updated_at_ms ) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`, normalizedApp, userID, transactionID, nowMs, coinAmount, usdMinorAmount, nowMs, nowMs, ) if err != nil { return 0, err } return 1, nil } if err != nil { return 0, err } next := count + 1 _, err = tx.ExecContext(ctx, ` UPDATE wallet_user_recharge_stats SET recharge_count = ?, total_coin_amount = total_coin_amount + ?, total_usd_minor_amount = total_usd_minor_amount + ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ?`, next, coinAmount, usdMinorAmount, nowMs, normalizedApp, userID, ) if err != nil { return 0, err } return next, nil } func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error { metadataJSON, err := json.Marshal(metadata) if err != nil { return err } var paymentRef any if metadata.PaymentRef != "" { paymentRef = metadata.PaymentRef } _, err = tx.ExecContext(ctx, `INSERT INTO coin_seller_stock_records ( app_code, stock_id, transaction_id, command_id, seller_user_id, stock_type, counts_as_seller_recharge, coin_amount, paid_currency_code, paid_amount_micro, payment_ref, evidence_ref, operator_user_id, reason, balance_after, metadata_json, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, normalizedEntryAppCode(ctx, metadata.AppCode), coinSellerStockID(metadata.AppCode, commandID), transactionID, commandID, metadata.SellerUserID, metadata.StockType, metadata.CountsAsSellerRecharge, metadata.CoinAmount, metadata.PaidCurrencyCode, metadata.PaidAmountMicro, paymentRef, metadata.EvidenceRef, metadata.OperatorUserID, metadata.Reason, metadata.BalanceAfter, string(metadataJSON), nowMs, ) if err != nil && isMySQLDuplicateError(err) && metadata.PaymentRef != "" { return xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated") } return err } func (r *Repository) coinSellerStockPaymentRefExists(ctx context.Context, tx *sql.Tx, stockType string, paymentRef string) (bool, error) { var stockID string err := tx.QueryRowContext(ctx, `SELECT stock_id FROM coin_seller_stock_records WHERE app_code = ? AND stock_type = ? AND payment_ref = ? LIMIT 1`, appcode.FromContext(ctx), stockType, paymentRef, ).Scan(&stockID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } return false, err } return true, nil } func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string { return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%d|%d|%s|%d|%s|%d|%s|%s|%d|%s", appcode.Normalize(command.AppCode), command.SellerUserID, command.SellerCountryID, command.SellerRegionID, command.StockType, command.CoinAmount, command.PaidCurrencyCode, command.PaidAmountMicro, command.PaymentRef, command.EvidenceRef, command.OperatorUserID, command.Reason, )) } func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) string { return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s", appcode.Normalize(command.AppCode), command.SellerUserID, command.TargetUserID, command.SellerRegionID, command.TargetRegionID, command.Amount, strings.TrimSpace(command.Reason), )) } func salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string { return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s", appcode.Normalize(command.AppCode), command.UserID, strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), command.SalaryUSDMinor, strings.TrimSpace(command.Reason), )) } func salaryTransferToCoinSellerRequestHash(command ledger.SalaryTransferToCoinSellerCommand) string { return stableHash(fmt.Sprintf("salary_transfer_coin_seller|%s|%d|%d|%s|%d|%d|%s", appcode.Normalize(command.AppCode), command.SourceUserID, command.SellerUserID, strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), command.SalaryUSDMinor, command.RegionID, strings.TrimSpace(command.Reason), )) } func coinSellerStockBizType(stockType string) string { switch stockType { case ledger.StockTypeUSDTPurchase: return bizTypeCoinSellerStockPurchase case ledger.StockTypeUSDTDeduction: return bizTypeCoinSellerStockDeduction case ledger.StockTypeCoinCompensation: return bizTypeCoinSellerCoinCompensation default: return "" } } func coinSellerStockAvailableDelta(command ledger.CoinSellerStockCreditCommand) int64 { if command.StockType == ledger.StockTypeUSDTDeduction { return -command.CoinAmount } return command.CoinAmount } func coinSellerStockPaidAmountMicro(command ledger.CoinSellerStockCreditCommand) int64 { if command.StockType == ledger.StockTypeUSDTDeduction { return -command.PaidAmountMicro } return command.PaidAmountMicro }