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