700 lines
28 KiB
Go
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
|
|
}
|