package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "math" "sort" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" _ "github.com/go-sql-driver/mysql" ) const ( transactionStatusSucceeded = "succeeded" bizTypeGiftDebit = "gift_debit" bizTypeManualCredit = "manual_credit" bizTypeCoinSellerTransfer = "coin_seller_transfer" outboxStatusPending = "pending" ) // Repository 是 wallet-service 的 MySQL v2 账本入口。 type Repository struct { // db 是账户、交易、分录和 outbox 的同一事务边界。 db *sql.DB } // Open 创建 MySQL 连接池并做启动 ping。 func Open(ctx context.Context, dsn string) (*Repository, error) { if strings.TrimSpace(dsn) == "" { return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required") } db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, err } return &Repository{db: db}, nil } // Close 释放 MySQL 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { return nil } return r.db.Close() } // Ping 验证 MySQL 当前可用。 func (r *Repository) Ping(ctx context.Context) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } return r.db.PingContext(ctx) } // DebitGift 在一个 MySQL 事务内完成 sender COIN 扣减、target GIFT_POINT 入账、分录和 outbox。 func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { if r == nil || r.db == nil { return ledger.Receipt{}, 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.Receipt{}, err } defer func() { // Commit 成功后 Rollback 会返回 sql.ErrTxDone;忽略它可保证所有早退路径释放事务。 _ = tx.Rollback() }() requestHash := debitRequestHash(command) if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeGiftDebit); err != nil || exists { if err != nil || !exists { return ledger.Receipt{}, err } receipt, receiptErr := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID) return receipt, receiptErr } nowMs := time.Now().UnixMilli() if command.RegionID < 0 { return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid") } giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID) if err != nil { return ledger.Receipt{}, err } price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs) if err != nil { return ledger.Receipt{}, err } coinSpent, err := checkedMul(price.CoinPrice, int64(command.GiftCount)) if err != nil { return ledger.Receipt{}, err } giftPointAdded, err := checkedMul(price.GiftPointAmount, int64(command.GiftCount)) if err != nil { return ledger.Receipt{}, err } heatValue, err := checkedMul(price.HeatValue, int64(command.GiftCount)) if err != nil { return ledger.Receipt{}, err } sender, err := r.lockAccount(ctx, tx, command.SenderUserID, ledger.AssetCoin, false, nowMs) if err != nil { return ledger.Receipt{}, err } if sender.AvailableAmount < coinSpent { return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetGiftPoint, true, nowMs) if err != nil { return ledger.Receipt{}, err } transactionID := transactionID(command.AppCode, command.CommandID) metadata := giftMetadata{ AppCode: command.AppCode, GiftID: command.GiftID, GiftName: giftConfig.Name, ResourceID: giftConfig.ResourceID, ResourceSnapshot: mustJSON(giftConfig.Resource), GiftCount: command.GiftCount, PriceVersion: price.PriceVersion, CoinSpent: coinSpent, GiftPointAdded: giftPointAdded, HeatValue: heatValue, BalanceAfter: sender.AvailableAmount - coinSpent, BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), SenderUserID: command.SenderUserID, TargetUserID: command.TargetUserID, RoomID: command.RoomID, CoinPrice: price.CoinPrice, GiftPointAmount: price.GiftPointAmount, HeatUnitValue: price.HeatValue, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { return ledger.Receipt{}, err } if err := r.applyAccountDelta(ctx, tx, sender, -coinSpent, 0, nowMs); err != nil { return ledger.Receipt{}, err } senderAfter := sender.AvailableAmount - coinSpent if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.SenderUserID, AssetType: ledger.AssetCoin, AvailableDelta: -coinSpent, FrozenDelta: 0, AvailableAfter: senderAfter, FrozenAfter: sender.FrozenAmount, CounterpartyUserID: command.TargetUserID, RoomID: command.RoomID, CreatedAtMS: nowMs, }); err != nil { return ledger.Receipt{}, err } if err := r.applyAccountDelta(ctx, tx, target, giftPointAdded, 0, nowMs); err != nil { return ledger.Receipt{}, err } targetAfter := target.AvailableAmount + giftPointAdded if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.TargetUserID, AssetType: ledger.AssetGiftPoint, AvailableDelta: giftPointAdded, FrozenDelta: 0, AvailableAfter: targetAfter, FrozenAfter: target.FrozenAmount, CounterpartyUserID: command.SenderUserID, RoomID: command.RoomID, CreatedAtMS: nowMs, }); err != nil { return ledger.Receipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -coinSpent, 0, senderAfter, sender.FrozenAmount, metadata, nowMs), balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, metadata, nowMs), giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -coinSpent, 0, metadata, nowMs), }); err != nil { return ledger.Receipt{}, err } if err := tx.Commit(); err != nil { return ledger.Receipt{}, err } return receiptFromGiftMetadata(transactionID, metadata), nil } // GetBalances 读取用户多资产余额;指定资产不存在时返回 0 投影,便于客户端稳定渲染。 func (r *Repository) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } assetTypes = normalizeAssetTypes(assetTypes) appCode := appcode.FromContext(ctx) query := `SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms FROM wallet_accounts WHERE app_code = ? AND user_id = ?` args := []any{appCode, userID} if len(assetTypes) > 0 { placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",") query += " AND asset_type IN (" + placeholders + ")" for _, assetType := range assetTypes { args = append(args, assetType) } } query += " ORDER BY asset_type" rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() byAsset := make(map[string]ledger.AssetBalance) for rows.Next() { var balance ledger.AssetBalance if err := rows.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount, &balance.Version, &balance.UpdatedAtMs); err != nil { return nil, err } byAsset[balance.AssetType] = balance } if err := rows.Err(); err != nil { return nil, err } if len(assetTypes) == 0 { balances := make([]ledger.AssetBalance, 0, len(byAsset)) for _, balance := range byAsset { balances = append(balances, balance) } sort.Slice(balances, func(i, j int) bool { return balances[i].AssetType < balances[j].AssetType }) return balances, nil } balances := make([]ledger.AssetBalance, 0, len(assetTypes)) for _, assetType := range assetTypes { if balance, ok := byAsset[assetType]; ok { balances = append(balances, balance) continue } balances = append(balances, ledger.AssetBalance{AppCode: appCode, UserID: userID, AssetType: assetType}) } return balances, nil } // AdminCreditAsset 在一个事务内写入后台入账交易、分录和 outbox。 func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) { if r == nil || r.db == nil { return ledger.AssetBalance{}, "", 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.AssetBalance{}, "", err } defer func() { _ = tx.Rollback() }() requestHash := adminCreditRequestHash(command) if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeManualCredit); err != nil || exists { if err != nil || !exists { return ledger.AssetBalance{}, "", err } balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.TargetUserID, command.AssetType) return balance, txRow.TransactionID, balanceErr } nowMs := time.Now().UnixMilli() account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, true, nowMs) if err != nil { return ledger.AssetBalance{}, "", err } transactionID := transactionID(command.AppCode, command.CommandID) metadata := adminCreditMetadata{ AppCode: command.AppCode, TargetUserID: command.TargetUserID, AssetType: command.AssetType, Amount: command.Amount, OperatorUserID: command.OperatorUserID, Reason: command.Reason, EvidenceRef: command.EvidenceRef, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeManualCredit, requestHash, command.EvidenceRef, metadata, nowMs); err != nil { return ledger.AssetBalance{}, "", err } if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil { return ledger.AssetBalance{}, "", err } balance := ledger.AssetBalance{ AppCode: command.AppCode, UserID: command.TargetUserID, AssetType: command.AssetType, AvailableAmount: account.AvailableAmount + command.Amount, FrozenAmount: account.FrozenAmount, Version: account.Version + 1, UpdatedAtMs: nowMs, } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.TargetUserID, AssetType: command.AssetType, AvailableDelta: command.Amount, FrozenDelta: 0, AvailableAfter: balance.AvailableAmount, FrozenAfter: balance.FrozenAmount, CreatedAtMS: nowMs, }); err != nil { return ledger.AssetBalance{}, "", err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, metadata, nowMs), }); err != nil { return ledger.AssetBalance{}, "", err } if err := tx.Commit(); err != nil { return ledger.AssetBalance{}, "", err } return balance, transactionID, 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) policy, err := r.resolveRechargePolicy(ctx, tx, command.TargetRegionID, nowMs) if err != nil { return ledger.CoinSellerTransferReceipt{}, err } rechargeUSDMinor, err := calculateRechargeUSDMinor(command.Amount, policy) 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, SellerRegionID: command.SellerRegionID, TargetRegionID: command.TargetRegionID, Amount: command.Amount, Reason: command.Reason, SellerAssetType: ledger.AssetCoinSellerCoin, TargetAssetType: ledger.AssetCoin, SellerBalanceAfter: sellerAfter, TargetBalanceAfter: targetAfter, RechargeUSDMinor: rechargeUSDMinor, RechargeCurrencyCode: policy.CurrencyCode, RechargePolicyID: policy.PolicyID, RechargePolicyVersion: policy.PolicyVersion, RechargePolicyCoinAmount: policy.CoinAmount, RechargePolicyUSDMinorAmount: policy.USDMinorAmount, } 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, metadata, nowMs), balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, 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 } type transactionRow struct { TransactionID string MetadataJSON string } func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string) (transactionRow, bool, error) { row := tx.QueryRowContext(ctx, `SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}') FROM wallet_transactions WHERE app_code = ? AND command_id = ? FOR UPDATE`, appcode.FromContext(ctx), commandID, ) var txRow transactionRow var storedHash string var storedBizType string if err := row.Scan(&txRow.TransactionID, &storedHash, &storedBizType, &txRow.MetadataJSON); err != nil { if errors.Is(err, sql.ErrNoRows) { return transactionRow{}, false, nil } return transactionRow{}, false, err } if storedHash != requestHash || storedBizType != bizType { // command_id 是钱包幂等主键,业务 payload 或命令类型变化必须 fail-closed。 return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict") } return txRow, true, nil } type walletAccount struct { AppCode string UserID int64 AssetType string AvailableAmount int64 FrozenAmount int64 Version int64 UpdatedAtMS int64 } func (r *Repository) lockAccount(ctx context.Context, tx *sql.Tx, userID int64, assetType string, createIfMissing bool, nowMs int64) (walletAccount, error) { account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType) if err != nil || exists || !createIfMissing { if err != nil { return walletAccount{}, err } if !exists { return walletAccount{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } return account, nil } if _, err := tx.ExecContext(ctx, `INSERT INTO wallet_accounts (app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms) VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, appcode.FromContext(ctx), userID, assetType, nowMs, nowMs, ); err != nil { return walletAccount{}, err } return r.queryRequiredAccountForUpdate(ctx, tx, userID, assetType) } func (r *Repository) queryAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, bool, error) { row := tx.QueryRowContext(ctx, `SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms FROM wallet_accounts WHERE app_code = ? AND user_id = ? AND asset_type = ? FOR UPDATE`, appcode.FromContext(ctx), userID, assetType, ) var account walletAccount if err := row.Scan(&account.AppCode, &account.UserID, &account.AssetType, &account.AvailableAmount, &account.FrozenAmount, &account.Version, &account.UpdatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return walletAccount{}, false, nil } return walletAccount{}, false, err } return account, true, nil } func (r *Repository) queryRequiredAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, error) { account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType) if err != nil { return walletAccount{}, err } if !exists { return walletAccount{}, xerr.New(xerr.Internal, "wallet account creation failed") } return account, nil } func (r *Repository) applyAccountDelta(ctx context.Context, tx *sql.Tx, account walletAccount, availableDelta int64, frozenDelta int64, nowMs int64) error { availableAfter := account.AvailableAmount + availableDelta frozenAfter := account.FrozenAmount + frozenDelta if availableAfter < 0 || frozenAfter < 0 || availableAfter > math.MaxInt64-frozenAfter { // 钱包负债不能出现负数,也不能因溢出绕过余额约束。 return xerr.New(xerr.LedgerConflict, "wallet balance delta is invalid") } result, err := tx.ExecContext(ctx, `UPDATE wallet_accounts SET available_amount = ?, frozen_amount = ?, version = version + 1, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`, availableAfter, frozenAfter, nowMs, account.AppCode, account.UserID, account.AssetType, account.Version, ) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows != 1 { return xerr.New(xerr.LedgerConflict, "wallet account version conflict") } return nil } type giftPrice struct { GiftID string PriceVersion string CoinPrice int64 GiftPointAmount int64 HeatValue int64 } type rechargePolicy struct { PolicyID int64 RegionID int64 PolicyVersion string CurrencyCode string CoinAmount int64 USDMinorAmount int64 EffectiveFromMs int64 } func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID string, priceVersion string, nowMs int64) (giftPrice, error) { var row *sql.Row if strings.TrimSpace(priceVersion) != "" { row = tx.QueryRowContext(ctx, `SELECT gift_id, price_version, coin_price, gift_point_amount, heat_value FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`, appcode.FromContext(ctx), giftID, priceVersion, nowMs, ) } else { row = tx.QueryRowContext(ctx, `SELECT gift_id, price_version, coin_price, gift_point_amount, heat_value FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ? ORDER BY effective_at_ms DESC, price_version DESC LIMIT 1`, appcode.FromContext(ctx), giftID, nowMs, ) } var price giftPrice if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil { if errors.Is(err, sql.ErrNoRows) { return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active") } return giftPrice{}, err } if price.CoinPrice <= 0 || price.GiftPointAmount < 0 || price.HeatValue < 0 { return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid") } return price, nil } func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) { row := tx.QueryRowContext(ctx, `SELECT policy_id, region_id, policy_version, currency_code, coin_amount, usd_minor_amount, effective_from_ms FROM wallet_recharge_policies WHERE app_code = ? AND region_id = ? AND status = 'active' AND effective_from_ms <= ? AND (effective_to_ms IS NULL OR effective_to_ms > ?) ORDER BY effective_from_ms DESC, policy_id DESC LIMIT 1`, appcode.FromContext(ctx), regionID, nowMs, nowMs, ) var policy rechargePolicy if err := row.Scan(&policy.PolicyID, &policy.RegionID, &policy.PolicyVersion, &policy.CurrencyCode, &policy.CoinAmount, &policy.USDMinorAmount, &policy.EffectiveFromMs); err != nil { if errors.Is(err, sql.ErrNoRows) { return rechargePolicy{}, xerr.New(xerr.NotFound, "recharge policy not found") } return rechargePolicy{}, err } if policy.CoinAmount <= 0 || policy.USDMinorAmount <= 0 || strings.TrimSpace(policy.CurrencyCode) == "" { return rechargePolicy{}, xerr.New(xerr.Internal, "recharge policy is invalid") } return policy, nil } func calculateRechargeUSDMinor(coinAmount int64, policy rechargePolicy) (int64, error) { numerator, err := checkedMul(coinAmount, policy.USDMinorAmount) if err != nil { return 0, err } if numerator%policy.CoinAmount != 0 { // 充值金额按最小货币单位入账;不能精确换算时拒绝,避免财务口径出现隐式四舍五入。 return 0, xerr.New(xerr.InvalidArgument, "coin amount does not match recharge policy") } return numerator / policy.CoinAmount, nil } func (r *Repository) insertTransaction(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, bizType string, requestHash string, externalRef string, metadata any, nowMs int64) error { metadataJSON, err := json.Marshal(metadata) if err != nil { return err } _, err = tx.ExecContext(ctx, `INSERT INTO wallet_transactions ( app_code, transaction_id, command_id, biz_type, status, request_hash, external_ref, metadata_json, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), transactionID, commandID, bizType, transactionStatusSucceeded, requestHash, externalRef, string(metadataJSON), nowMs, nowMs, ) return err } 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, 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.SellerUserID, metadata.SellerRegionID, metadata.TargetRegionID, metadata.RechargePolicyID, metadata.RechargePolicyVersion, metadata.RechargeCurrencyCode, metadata.Amount, metadata.RechargeUSDMinor, metadata.RechargePolicyCoinAmount, metadata.RechargePolicyUSDMinorAmount, nowMs, ) return err } type walletEntry struct { AppCode string TransactionID string UserID int64 AssetType string AvailableDelta int64 FrozenDelta int64 AvailableAfter int64 FrozenAfter int64 CounterpartyUserID int64 RoomID string CreatedAtMS int64 } func (r *Repository) insertEntry(ctx context.Context, tx *sql.Tx, entry walletEntry) error { _, err := tx.ExecContext(ctx, `INSERT INTO wallet_entries ( app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta, available_after, frozen_after, counterparty_user_id, room_id, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, normalizedEntryAppCode(ctx, entry.AppCode), entry.TransactionID, entry.UserID, entry.AssetType, entry.AvailableDelta, entry.FrozenDelta, entry.AvailableAfter, entry.FrozenAfter, entry.CounterpartyUserID, entry.RoomID, entry.CreatedAtMS, ) return err } type walletOutboxEvent struct { AppCode string EventID string EventType string TransactionID string CommandID string UserID int64 AssetType string AvailableDelta int64 FrozenDelta int64 Payload any CreatedAtMS int64 } func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events []walletOutboxEvent) error { for _, event := range events { payloadJSON, err := json.Marshal(event.Payload) if err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO wallet_outbox ( app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, normalizedEntryAppCode(ctx, event.AppCode), event.EventID, event.EventType, event.TransactionID, event.CommandID, event.UserID, event.AssetType, event.AvailableDelta, event.FrozenDelta, string(payloadJSON), outboxStatusPending, event.CreatedAtMS, event.CreatedAtMS, ); err != nil { return err } } return nil } func (r *Repository) receiptForGiftTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.Receipt, error) { var metadataJSON string if err := tx.QueryRowContext(ctx, `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') FROM wallet_transactions WHERE app_code = ? AND transaction_id = ?`, appcode.FromContext(ctx), transactionID, ).Scan(&metadataJSON); err != nil { return ledger.Receipt{}, err } var metadata giftMetadata if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { return ledger.Receipt{}, err } return receiptFromGiftMetadata(transactionID, metadata), nil } func (r *Repository) balanceAfterTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64, assetType string) (ledger.AssetBalance, error) { row := tx.QueryRowContext(ctx, `SELECT app_code, user_id, asset_type, available_after, frozen_after FROM wallet_entries WHERE app_code = ? AND transaction_id = ? AND user_id = ? AND asset_type = ? ORDER BY entry_id DESC LIMIT 1`, appcode.FromContext(ctx), transactionID, userID, assetType, ) var balance ledger.AssetBalance if err := row.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount); err != nil { return ledger.AssetBalance{}, err } return balance, nil } type giftMetadata struct { AppCode string `json:"app_code"` GiftID string `json:"gift_id"` GiftName string `json:"gift_name"` ResourceID int64 `json:"resource_id"` ResourceSnapshot string `json:"resource_snapshot_json"` GiftCount int32 `json:"gift_count"` PriceVersion string `json:"price_version"` CoinPrice int64 `json:"coin_price"` GiftPointAmount int64 `json:"gift_point_amount"` HeatUnitValue int64 `json:"heat_unit_value"` CoinSpent int64 `json:"coin_spent"` GiftPointAdded int64 `json:"gift_point_added"` HeatValue int64 `json:"heat_value"` BalanceAfter int64 `json:"balance_after"` BillingReceipt string `json:"billing_receipt_id"` SenderUserID int64 `json:"sender_user_id"` TargetUserID int64 `json:"target_user_id"` RoomID string `json:"room_id"` } type adminCreditMetadata struct { AppCode string `json:"app_code"` TargetUserID int64 `json:"target_user_id"` AssetType string `json:"asset_type"` Amount int64 `json:"amount"` OperatorUserID int64 `json:"operator_user_id"` Reason string `json:"reason"` EvidenceRef string `json:"evidence_ref"` } type coinSellerTransferMetadata struct { AppCode string `json:"app_code"` SellerUserID int64 `json:"seller_user_id"` TargetUserID int64 `json:"target_user_id"` SellerRegionID int64 `json:"seller_region_id"` TargetRegionID int64 `json:"target_region_id"` Amount int64 `json:"amount"` Reason string `json:"reason"` SellerAssetType string `json:"seller_asset_type"` TargetAssetType string `json:"target_asset_type"` SellerBalanceAfter int64 `json:"seller_balance_after"` TargetBalanceAfter int64 `json:"target_balance_after"` RechargeUSDMinor int64 `json:"recharge_usd_minor"` RechargeCurrencyCode string `json:"recharge_currency_code"` RechargePolicyID int64 `json:"recharge_policy_id"` RechargePolicyVersion string `json:"recharge_policy_version"` RechargePolicyCoinAmount int64 `json:"recharge_policy_coin_amount"` RechargePolicyUSDMinorAmount int64 `json:"recharge_policy_usd_minor_amount"` } func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt { return ledger.Receipt{ BillingReceiptID: metadata.BillingReceipt, TransactionID: transactionID, CoinSpent: metadata.CoinSpent, GiftPointAdded: metadata.GiftPointAdded, HeatValue: metadata.HeatValue, PriceVersion: metadata.PriceVersion, BalanceAfter: metadata.BalanceAfter, } } func (r *Repository) receiptForCoinSellerTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerTransferReceipt, error) { var metadataJSON string if err := tx.QueryRowContext(ctx, `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') FROM wallet_transactions WHERE app_code = ? AND transaction_id = ?`, appcode.FromContext(ctx), transactionID, ).Scan(&metadataJSON); err != nil { return ledger.CoinSellerTransferReceipt{}, err } var metadata coinSellerTransferMetadata if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { return ledger.CoinSellerTransferReceipt{}, err } return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil } func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSellerTransferMetadata) ledger.CoinSellerTransferReceipt { return ledger.CoinSellerTransferReceipt{ TransactionID: transactionID, SellerBalanceAfter: metadata.SellerBalanceAfter, TargetBalanceAfter: metadata.TargetBalanceAfter, Amount: metadata.Amount, RechargeUSDMinor: metadata.RechargeUSDMinor, RechargeCurrencyCode: metadata.RechargeCurrencyCode, RechargePolicyID: metadata.RechargePolicyID, RechargePolicyVersion: metadata.RechargePolicyVersion, RechargePolicyCoinAmount: metadata.RechargePolicyCoinAmount, RechargePolicyUSDMinorUnit: metadata.RechargePolicyUSDMinorAmount, } } func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, payload any, nowMs int64) walletOutboxEvent { return walletOutboxEvent{ EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType), EventType: "WalletBalanceChanged", TransactionID: transactionID, CommandID: commandID, UserID: userID, AssetType: assetType, AvailableDelta: availableDelta, FrozenDelta: frozenDelta, Payload: map[string]any{ "transaction_id": transactionID, "command_id": commandID, "user_id": userID, "asset_type": assetType, "available_delta": availableDelta, "frozen_delta": frozenDelta, "available_after": availableAfter, "frozen_after": frozenAfter, "metadata": payload, "created_at_ms": nowMs, }, CreatedAtMS: nowMs, } } func coinSellerTransferredEvent(transactionID string, commandID string, sellerUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { return walletOutboxEvent{ EventID: eventID(transactionID, "WalletCoinSellerTransferred", sellerUserID, ledger.AssetCoinSellerCoin), EventType: "WalletCoinSellerTransferred", TransactionID: transactionID, CommandID: commandID, UserID: sellerUserID, AssetType: ledger.AssetCoinSellerCoin, AvailableDelta: availableDelta, FrozenDelta: 0, Payload: payload, CreatedAtMS: nowMs, } } func rechargeRecordedEvent(transactionID string, commandID string, targetUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { return walletOutboxEvent{ EventID: eventID(transactionID, "WalletRechargeRecorded", targetUserID, ledger.AssetCoin), EventType: "WalletRechargeRecorded", TransactionID: transactionID, CommandID: commandID, UserID: targetUserID, AssetType: ledger.AssetCoin, AvailableDelta: availableDelta, FrozenDelta: 0, Payload: payload, CreatedAtMS: nowMs, } } func giftDebitedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, payload any, nowMs int64) walletOutboxEvent { return walletOutboxEvent{ EventID: eventID(transactionID, "WalletGiftDebited", userID, assetType), EventType: "WalletGiftDebited", TransactionID: transactionID, CommandID: commandID, UserID: userID, AssetType: assetType, AvailableDelta: availableDelta, FrozenDelta: frozenDelta, Payload: payload, CreatedAtMS: nowMs, } } func normalizeAssetTypes(assetTypes []string) []string { seen := make(map[string]bool, len(assetTypes)) result := make([]string, 0, len(assetTypes)) for _, assetType := range assetTypes { assetType = strings.ToUpper(strings.TrimSpace(assetType)) if assetType == "" || seen[assetType] { continue } seen[assetType] = true result = append(result, assetType) } return result } func contextWithCommandApp(ctx context.Context, value string) context.Context { if strings.TrimSpace(value) == "" { return appcode.WithContext(ctx, appcode.FromContext(ctx)) } return appcode.WithContext(ctx, value) } func normalizedEntryAppCode(ctx context.Context, value string) string { if strings.TrimSpace(value) == "" { return appcode.FromContext(ctx) } return appcode.Normalize(value) } func checkedMul(unit int64, count int64) (int64, error) { if unit < 0 || count <= 0 { return 0, xerr.New(xerr.InvalidArgument, "amount is invalid") } if unit != 0 && count > math.MaxInt64/unit { return 0, xerr.New(xerr.InvalidArgument, "amount overflow") } return unit * count, nil } func debitRequestHash(command ledger.DebitGiftCommand) string { // 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。 return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s", appcode.Normalize(command.AppCode), command.RoomID, command.SenderUserID, command.TargetUserID, command.GiftID, command.GiftCount, strings.TrimSpace(command.PriceVersion), )) } func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string { return stableHash(fmt.Sprintf("admin_credit|%s|%d|%s|%d|%d|%s|%s", appcode.Normalize(command.AppCode), command.TargetUserID, command.AssetType, command.Amount, command.OperatorUserID, command.Reason, command.EvidenceRef, )) } 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 stableHash(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } func mustJSON(value any) string { body, err := json.Marshal(value) if err != nil { return "{}" } return string(body) } func transactionID(appCode string, commandID string) string { return "wtx_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) } func billingReceiptID(appCode string, commandID string) string { return "bill_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) } func eventID(transactionID string, eventType string, userID int64, assetType string) string { return "wev_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", transactionID, eventType, userID, assetType)) }