2026-06-28 15:11:56 +08:00

700 lines
28 KiB
Go

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
}