2026-04-30 02:30:32 +08:00

761 lines
25 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/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))
}