2026-05-02 13:02:38 +08:00

1109 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
}