761 lines
25 KiB
Go
761 lines
25 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"
|
||
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")
|
||
}
|
||
|
||
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()
|
||
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.CommandID)
|
||
metadata := giftMetadata{
|
||
GiftID: command.GiftID,
|
||
GiftCount: command.GiftCount,
|
||
PriceVersion: price.PriceVersion,
|
||
CoinSpent: coinSpent,
|
||
GiftPointAdded: giftPointAdded,
|
||
HeatValue: heatValue,
|
||
BalanceAfter: sender.AvailableAmount - coinSpent,
|
||
BillingReceipt: billingReceiptID(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)
|
||
|
||
query := `SELECT user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms
|
||
FROM wallet_accounts WHERE user_id = ?`
|
||
args := []any{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.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{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")
|
||
}
|
||
|
||
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.CommandID)
|
||
metadata := adminCreditMetadata{
|
||
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{
|
||
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
|
||
}
|
||
|
||
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 command_id = ?
|
||
FOR UPDATE`,
|
||
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 {
|
||
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 (user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, 0, 0, 1, ?, ?)`,
|
||
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 user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms
|
||
FROM wallet_accounts
|
||
WHERE user_id = ? AND asset_type = ?
|
||
FOR UPDATE`,
|
||
userID, assetType,
|
||
)
|
||
var account walletAccount
|
||
if err := row.Scan(&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 user_id = ? AND asset_type = ? AND version = ?`,
|
||
availableAfter,
|
||
frozenAfter,
|
||
nowMs,
|
||
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
|
||
}
|
||
|
||
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 gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`,
|
||
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 gift_id = ? AND status = 'active' AND effective_at_ms <= ?
|
||
ORDER BY effective_at_ms DESC, price_version DESC
|
||
LIMIT 1`,
|
||
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) 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 (
|
||
transaction_id, command_id, biz_type, status, request_hash, external_ref, metadata_json, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
transactionID,
|
||
commandID,
|
||
bizType,
|
||
transactionStatusSucceeded,
|
||
requestHash,
|
||
externalRef,
|
||
string(metadataJSON),
|
||
nowMs,
|
||
nowMs,
|
||
)
|
||
return err
|
||
}
|
||
|
||
type walletEntry struct {
|
||
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 (
|
||
transaction_id, user_id, asset_type, available_delta, frozen_delta,
|
||
available_after, frozen_after, counterparty_user_id, room_id, created_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
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 {
|
||
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 (
|
||
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, ?, ?)`,
|
||
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 transaction_id = ?`,
|
||
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 user_id, asset_type, available_after, frozen_after
|
||
FROM wallet_entries
|
||
WHERE transaction_id = ? AND user_id = ? AND asset_type = ?
|
||
ORDER BY entry_id DESC LIMIT 1`,
|
||
transactionID, userID, assetType,
|
||
)
|
||
var balance ledger.AssetBalance
|
||
if err := row.Scan(&balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount); err != nil {
|
||
return ledger.AssetBalance{}, err
|
||
}
|
||
return balance, nil
|
||
}
|
||
|
||
type giftMetadata struct {
|
||
GiftID string `json:"gift_id"`
|
||
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 {
|
||
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"`
|
||
}
|
||
|
||
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 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 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 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|%d|%d|%s|%d|%s",
|
||
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|%d|%s|%d|%d|%s|%s",
|
||
command.TargetUserID,
|
||
command.AssetType,
|
||
command.Amount,
|
||
command.OperatorUserID,
|
||
command.Reason,
|
||
command.EvidenceRef,
|
||
))
|
||
}
|
||
|
||
func stableHash(value string) string {
|
||
sum := sha256.Sum256([]byte(value))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func transactionID(commandID string) string {
|
||
return "wtx_" + stableHash(commandID)
|
||
}
|
||
|
||
func billingReceiptID(commandID string) string {
|
||
return "bill_" + stableHash(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))
|
||
}
|