2026-06-11 01:02:20 +08:00

3918 lines
154 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"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
mysqlDriver "github.com/go-sql-driver/mysql"
)
const (
transactionStatusSucceeded = "succeeded"
bizTypeGiftDebit = "gift_debit"
bizTypeManualCredit = "manual_credit"
bizTypeTaskReward = "task_reward"
bizTypeLuckyGiftReward = "lucky_gift_reward"
bizTypeRoomTurnoverReward = "room_turnover_reward"
bizTypeCoinSellerTransfer = "coin_seller_transfer"
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
bizTypeSalaryExchangeToCoin = "salary_exchange_to_coin"
bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller"
bizTypeGooglePlayRecharge = "google_play_recharge"
bizTypeMifaPayRecharge = "mifapay"
bizTypeUSDTTRC20Recharge = "usdt_trc20"
bizTypeGameDebit = "game_debit"
bizTypeGameCredit = "game_credit"
bizTypeGameRefund = "game_refund"
bizTypeGameReverse = "game_reverse"
bizTypeHostSalarySettlement = "host_salary_settlement"
bizTypeCPBreakupFee = "cp_breakup_fee"
outboxStatusPending = "pending"
outboxStatusDelivering = "delivering"
outboxStatusDelivered = "delivered"
outboxStatusRetryable = "retryable"
outboxStatusFailed = "failed"
giftWallChargeAssetMixed = "MIXED"
)
// Repository 是 wallet-service 的 MySQL v2 账本入口。
type Repository struct {
// db 是账户、交易、分录和 outbox 的同一事务边界。
db *sql.DB
}
// WalletOutboxRecord is a committed wallet fact ready for external fanout.
type WalletOutboxRecord struct {
AppCode string
EventID string
EventType string
TransactionID string
CommandID string
UserID int64
AssetType string
AvailableDelta int64
FrozenDelta int64
PayloadJSON string
CreatedAtMS int64
RetryCount int
LastError string
WorkerID string
LockUntilMS int64
}
// WalletOutboxClaimFilter narrows one MQ fanout lane by event type.
type WalletOutboxClaimFilter struct {
// IncludeEventTypes lets a realtime worker own only the configured UI event facts.
IncludeEventTypes []string
// ExcludeEventTypes lets the generic wallet worker skip facts owned by the realtime lane.
ExcludeEventTypes []string
}
// 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
}
if err := ensureGiftDiamondRatioSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
if err := ensureCoinSellerSalaryExchangeRateSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
if err := ensureUserResourceEquipmentSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
if err := ensureExternalRechargeSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}
func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
ALTER TABLE wallet_recharge_products
ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '充值档位适用用户类型normal/coin_seller' AFTER app_code`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS third_party_payment_channels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码',
provider_name VARCHAR(64) NOT NULL COMMENT '三方支付渠道名称',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, provider_code),
KEY idx_third_party_payment_channels_status (app_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付渠道配置表'`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS third_party_payment_methods (
method_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '支付方式 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
provider_code VARCHAR(32) NOT NULL COMMENT '三方支付渠道编码',
country_code VARCHAR(16) NOT NULL COMMENT 'MiFaPay countryCodeGLOBAL 表示全球方式',
country_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '国家/地区名称',
currency_code VARCHAR(8) NOT NULL COMMENT '下单币种',
pay_way VARCHAR(64) NOT NULL COMMENT 'MiFaPay payWay',
pay_type VARCHAR(64) NOT NULL COMMENT 'MiFaPay payType',
method_name VARCHAR(128) NOT NULL COMMENT '支付方式展示名',
logo_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '支付方式 logo',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
usd_to_currency_rate DECIMAL(24,8) NOT NULL DEFAULT 0 COMMENT '1 USD 可兑换本币金额0 表示未配置,不在 H5 展示',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (method_id),
UNIQUE KEY uk_third_party_payment_method (app_code, provider_code, country_code, pay_way, pay_type),
KEY idx_third_party_payment_methods_country (app_code, provider_code, country_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方支付国家和方式配置表'`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS external_recharge_orders (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
order_id VARCHAR(96) NOT NULL COMMENT '平台外部充值订单 ID',
command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID',
target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID',
target_region_id BIGINT NOT NULL COMMENT '充值目标区域 ID',
target_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '充值目标国家码',
audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型',
product_id BIGINT NOT NULL COMMENT '充值商品 ID',
product_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品编码快照',
product_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '充值商品名称快照',
coin_amount BIGINT NOT NULL COMMENT '到账金币数量',
usd_minor_amount BIGINT NOT NULL COMMENT '美元最小单位金额',
provider_code VARCHAR(32) NOT NULL COMMENT '支付渠道mifapay/usdt_trc20',
payment_method_id BIGINT NOT NULL DEFAULT 0 COMMENT '三方支付方式 ID',
country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '支付国家码',
currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '支付币种',
provider_amount_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方下单金额最小单位',
pay_way VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payWay',
pay_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'MiFaPay payType',
pay_url TEXT NULL COMMENT '三方收银台地址',
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '三方订单号',
tx_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT-TRC20 交易哈希',
receive_address VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT 收款地址',
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/redirected/credited/failed',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '入账钱包交易 ID',
provider_payload JSON NULL COMMENT '三方原始响应或链上交易快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, order_id),
UNIQUE KEY uk_external_recharge_command (app_code, command_id),
KEY idx_external_recharge_user_time (app_code, target_user_id, created_at_ms),
KEY idx_external_recharge_provider_order (app_code, provider_code, provider_order_id),
KEY idx_external_recharge_tx (app_code, provider_code, tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil {
return err
}
return seedThirdPartyPaymentDefaults(ctx, db)
}
func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域0 表示全局默认',
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例,百分比',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, region_id, gift_type_code),
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil {
return err
}
_, err := db.ExecContext(ctx, `
INSERT IGNORE INTO gift_diamond_ratio_configs (
app_code, region_id, gift_type_code, status, ratio_percent,
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES
('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0),
('lalu', 0, 'lucky', 'active', 10.00, 0, 0, 0, 0),
('lalu', 0, 'super_lucky', 'active', 1.00, 0, 0, 0, 0)`)
return err
}
func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
region_id BIGINT NOT NULL COMMENT '币商所属区域 ID',
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID',
min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额,单位美分,包含',
max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额,单位美分,包含',
coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态active/disabled',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (tier_id),
KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor),
KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`)
return err
}
func ensureUserResourceEquipmentSchema(ctx context.Context, db *sql.DB) error {
// 徽章现在是“拥有即佩戴”的多选资源,装备表必须允许同一用户同一类型下存在多条 entitlement。
// 非徽章仍由写入逻辑先删同类型旧装备再插入新装备,避免把 schema 的多行能力泄漏成其它装扮多穿。
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS user_resource_equipment (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '用户 ID',
resource_type VARCHAR(32) NOT NULL COMMENT '资源类型',
entitlement_id VARCHAR(96) NOT NULL COMMENT '权益 ID',
resource_id BIGINT NOT NULL COMMENT '资源 ID',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, resource_type, entitlement_id),
KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表'`); err != nil {
return err
}
primaryColumns, err := tablePrimaryKeyColumns(ctx, db, "user_resource_equipment")
if err != nil {
return err
}
if strings.Join(primaryColumns, ",") == "app_code,user_id,resource_type" {
_, err = db.ExecContext(ctx, `
ALTER TABLE user_resource_equipment
DROP PRIMARY KEY,
ADD PRIMARY KEY (app_code, user_id, resource_type, entitlement_id)`)
return err
}
return nil
}
func tablePrimaryKeyColumns(ctx context.Context, db *sql.DB, tableName string) ([]string, error) {
rows, err := db.QueryContext(ctx, `
SELECT COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = 'PRIMARY'
ORDER BY ORDINAL_POSITION ASC`, tableName)
if err != nil {
return nil, err
}
defer rows.Close()
columns := make([]string, 0, 4)
for rows.Next() {
var column string
if err := rows.Scan(&column); err != nil {
return nil, err
}
columns = append(columns, column)
}
return columns, rows.Err()
}
// 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 扣费、主播周期钻石入账、分录和 outbox历史 GIFT_POINT 回执字段保留但不再入账。
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")
}
if command.SenderRegionID < 0 {
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "sender_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
}
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
if err != nil {
return ledger.Receipt{}, err
}
// 礼物积分已经从运营配置和收礼收益里下线;回执字段只保留 0兼容旧客户端和历史事件解析。
roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
if err != nil {
return ledger.Receipt{}, err
}
// 房间贡献只以真实扣费 COIN 为基数;旧 gift price heat_value 不再参与结算,避免后台热度配置覆盖实际消费。
heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM)
if err != nil {
return ledger.Receipt{}, err
}
hostPeriodDiamondAdded := int64(0)
hostPeriodCycleKey := ""
giftDiamondRatioPercent := "0.00"
giftDiamondRatioRegionID := int64(0)
if command.TargetIsHost {
ratio, ratioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
if err != nil {
return ledger.Receipt{}, err
}
// 非主播不会进入该分支,因此普通用户收礼不会产生工资周期钻石。
hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, ratio.PPM)
if err != nil {
return ledger.Receipt{}, err
}
giftDiamondRatioPercent = ratio.Percent
giftDiamondRatioRegionID = ratioRegionID
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, chargeAmount == 0, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
if sender.AvailableAmount < chargeAmount {
return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
transactionID := transactionID(command.AppCode, command.CommandID)
metadata := giftMetadata{
AppCode: command.AppCode,
GiftID: command.GiftID,
GiftName: giftConfig.Name,
GiftIconURL: giftDisplayIconURL(giftConfig.Resource),
GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL),
// effect_types 是后台控制动画、音乐、全局广播的单一来源,账务快照保存后可以被房间事件稳定复用。
GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes),
ResourceID: giftConfig.ResourceID,
ResourceSnapshot: mustJSON(giftConfig.Resource),
GiftTypeCode: giftConfig.GiftTypeCode,
CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON),
PresentationJSON: giftConfig.PresentationJSON,
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
GiftPointAdded: 0,
HeatValue: heatValue,
BalanceAfter: sender.AvailableAmount - chargeAmount,
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
TargetUserID: command.TargetUserID,
TargetIsHost: command.TargetIsHost,
TargetHostRegionID: command.TargetHostRegionID,
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
RoomID: command.RoomID,
RoomRegionID: command.RegionID,
RoomContributionRatioPercent: roomContributionRatio.Percent,
RoomContributionRatioRegionID: roomContributionRatioRegionID,
CoinPrice: price.CoinPrice,
GiftPointAmount: 0,
HeatUnitValue: price.CoinPrice,
}
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, -chargeAmount, 0, nowMs); err != nil {
return ledger.Receipt{}, err
}
senderAfter := sender.AvailableAmount - chargeAmount
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: price.ChargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
CounterpartyUserID: command.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.Receipt{}, err
}
if hostPeriodDiamondAdded > 0 {
// 周期钻石与送礼扣费同事务提交;如果后续任一步失败,用户扣币和主播工资周期钻石会一起回滚。
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter
metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
}
events := []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs),
giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs),
}
if hostPeriodDiamondAdded > 0 {
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
}
if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
return ledger.Receipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.Receipt{}, err
}
return receiptFromGiftMetadata(transactionID, metadata), nil
}
// BatchDebitGift 在一个 MySQL 事务内完成多目标送礼;任一目标失败时整批回滚,避免房间命令和账务出现部分成功。
func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) {
if r == nil || r.db == nil {
return ledger.BatchGiftReceipt{}, 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.BatchGiftReceipt{}, err
}
defer func() {
// Commit 成功后 Rollback 会返回 sql.ErrTxDone忽略它可保证所有早退路径释放事务。
_ = tx.Rollback()
}()
nowMs := time.Now().UnixMilli()
if command.RegionID < 0 {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid")
}
if command.SenderRegionID < 0 {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid")
}
giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
// 礼物积分已经下线;批量回执仍保留字段,但每个目标都固定返回 0。
roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
// 批量多目标仍然是每个目标各送一份同款礼物;每份房间贡献先按真实扣费折算,再由聚合回执累加。
heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
totalChargeAmount, err := checkedMul(chargeAmount, int64(len(command.Targets)))
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
// 批量命令的每个目标都有独立 wallet command_id幂等重试必须是“全部已存在”或“全部未存在”不接受半批历史。
existing := make(map[string]ledger.BatchGiftTargetReceipt, len(command.Targets))
for _, target := range command.Targets {
targetCommand := debitGiftCommandFromBatchTarget(command, target)
requestHash := debitRequestHash(targetCommand)
txRow, exists, err := r.lookupTransaction(ctx, tx, target.CommandID, requestHash, bizTypeGiftDebit)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
if !exists {
continue
}
receipt, err := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
existing[target.CommandID] = ledger.BatchGiftTargetReceipt{
TargetUserID: target.TargetUserID,
CommandID: target.CommandID,
Receipt: receipt,
}
}
if len(existing) > 0 {
if len(existing) != len(command.Targets) {
// 同一个 room 命令不应该只存在部分 wallet 子交易;直接 fail-close等待人工核对账本。
return ledger.BatchGiftReceipt{}, xerr.New(xerr.IdempotencyConflict, "wallet batch command idempotency conflict")
}
targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
for _, target := range command.Targets {
targetReceipts = append(targetReceipts, existing[target.CommandID])
}
aggregate, err := aggregateGiftReceipts(targetReceipts)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, totalChargeAmount == 0, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
if sender.AvailableAmount < totalChargeAmount {
return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
anyHostTarget := false
for _, target := range command.Targets {
if target.TargetIsHost {
anyHostTarget = true
break
}
}
hostRatio := giftDiamondRatioSnapshot{Percent: "0.00", PPM: 0}
hostRatioRegionID := int64(0)
if anyHostTarget {
hostRatio, hostRatioRegionID, err = r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
}
events := make([]walletOutboxEvent, 0, len(command.Targets)*3)
targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
for _, target := range command.Targets {
hostPeriodDiamondAdded := int64(0)
hostPeriodCycleKey := ""
giftDiamondRatioPercent := "0.00"
giftDiamondRatioRegionID := int64(0)
if target.TargetIsHost {
// 每个目标只使用自己的 host 快照;不能把某个主播的 agency/region 套给批量里的其他用户。
hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, hostRatio.PPM)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
giftDiamondRatioPercent = hostRatio.Percent
giftDiamondRatioRegionID = hostRatioRegionID
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
}
transactionID := transactionID(command.AppCode, target.CommandID)
senderAfter := sender.AvailableAmount - chargeAmount
metadata := giftMetadata{
AppCode: command.AppCode,
GiftID: command.GiftID,
GiftName: giftConfig.Name,
GiftIconURL: giftDisplayIconURL(giftConfig.Resource),
GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL),
// 批量送礼复用单礼物配置快照,确保每个接收方产生的 RoomGiftSent 都带同一份 effect_types。
GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes),
ResourceID: giftConfig.ResourceID,
ResourceSnapshot: mustJSON(giftConfig.Resource),
GiftTypeCode: giftConfig.GiftTypeCode,
CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON),
PresentationJSON: giftConfig.PresentationJSON,
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
GiftPointAdded: 0,
HeatValue: heatValue,
BalanceAfter: senderAfter,
BillingReceipt: billingReceiptID(command.AppCode, target.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
TargetUserID: target.TargetUserID,
TargetIsHost: target.TargetIsHost,
TargetHostRegionID: target.TargetHostRegionID,
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
RoomID: command.RoomID,
RoomRegionID: command.RegionID,
RoomContributionRatioPercent: roomContributionRatio.Percent,
RoomContributionRatioRegionID: roomContributionRatioRegionID,
CoinPrice: price.CoinPrice,
GiftPointAmount: 0,
HeatUnitValue: price.CoinPrice,
}
if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
return ledger.BatchGiftReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
return ledger.BatchGiftReceipt{}, err
}
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
sender.AvailableAmount = senderAfter
sender.Version++
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: price.ChargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
FrozenAfter: sender.FrozenAmount,
CounterpartyUserID: target.TargetUserID,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.BatchGiftReceipt{}, err
}
if hostPeriodDiamondAdded > 0 {
hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter
metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
}
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs))
if hostPeriodDiamondAdded > 0 {
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
}
targetReceipts = append(targetReceipts, ledger.BatchGiftTargetReceipt{
TargetUserID: target.TargetUserID,
CommandID: target.CommandID,
Receipt: receiptFromGiftMetadata(transactionID, metadata),
})
}
if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
return ledger.BatchGiftReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.BatchGiftReceipt{}, err
}
aggregate, err := aggregateGiftReceipts(targetReceipts)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, 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
}
func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadata giftMetadata, nowMs int64) error {
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
chargeAmount := metadata.ChargeAmount
if chargeAmount == 0 {
// metadata 同时保留 coin_spent 和 charge_amount投影使用最终收费金额作为礼物价值。
chargeAmount = metadata.CoinSpent
}
totalCoinValue, totalDiamondValue := chargeAmount, int64(0)
resourceSnapshotJSON := strings.TrimSpace(metadata.ResourceSnapshot)
if resourceSnapshotJSON == "" {
resourceSnapshotJSON = "{}"
}
presentationJSON := strings.TrimSpace(metadata.PresentationJSON)
if presentationJSON == "" {
presentationJSON = "{}"
}
giftTypeCode := strings.TrimSpace(metadata.GiftTypeCode)
if giftTypeCode == "" {
giftTypeCode = "normal"
}
_, err := tx.ExecContext(ctx, `
INSERT INTO user_gift_wall (
app_code, user_id, gift_id, gift_name, resource_id, resource_snapshot_json,
gift_type_code, presentation_json, gift_count, total_value, total_coin_value,
total_diamond_value, total_gift_point, total_heat_value, charge_asset_type,
last_price_version, first_received_at_ms, last_received_at_ms, sort_order,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gift_name = VALUES(gift_name),
resource_id = VALUES(resource_id),
resource_snapshot_json = VALUES(resource_snapshot_json),
gift_type_code = VALUES(gift_type_code),
presentation_json = VALUES(presentation_json),
gift_count = gift_count + VALUES(gift_count),
total_value = total_value + VALUES(total_value),
total_coin_value = total_coin_value + VALUES(total_coin_value),
total_diamond_value = total_diamond_value + VALUES(total_diamond_value),
total_gift_point = total_gift_point + VALUES(total_gift_point),
total_heat_value = total_heat_value + VALUES(total_heat_value),
charge_asset_type = IF(charge_asset_type = VALUES(charge_asset_type), charge_asset_type, ?),
last_price_version = VALUES(last_price_version),
last_received_at_ms = VALUES(last_received_at_ms),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms)
`,
appcode.FromContext(ctx),
metadata.TargetUserID,
metadata.GiftID,
metadata.GiftName,
metadata.ResourceID,
resourceSnapshotJSON,
giftTypeCode,
presentationJSON,
int64(metadata.GiftCount),
chargeAmount,
totalCoinValue,
totalDiamondValue,
0,
metadata.HeatValue,
chargeAssetType,
metadata.PriceVersion,
nowMs,
nowMs,
metadata.SortOrder,
nowMs,
nowMs,
giftWallChargeAssetMixed,
)
return err
}
// 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()
// 扣账不能隐式创建 0 余额账户;入账可以创建账户,保证运营补发能落到新用户。
account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, command.Amount > 0, nowMs)
if err != nil {
return ledger.AssetBalance{}, "", err
}
if command.Amount < 0 && (command.Amount == math.MinInt64 || account.AvailableAmount < -command.Amount) {
return ledger.AssetBalance{}, "", xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
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, balance.Version, metadata, nowMs),
}); err != nil {
return ledger.AssetBalance{}, "", err
}
if err := tx.Commit(); err != nil {
return ledger.AssetBalance{}, "", err
}
return balance, transactionID, nil
}
// DebitCPBreakupFee 在钱包账本中扣除解除关系费用;关系状态不在 wallet 中修改,避免 wallet 反向拥有用户关系事实。
func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) {
if r == nil || r.db == nil {
return ledger.CPBreakupFeeReceipt{}, 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.CPBreakupFeeReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := cpBreakupFeeRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCPBreakupFee); err != nil || exists {
if err != nil || !exists {
return ledger.CPBreakupFeeReceipt{}, err
}
balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin)
if balanceErr != nil {
return ledger.CPBreakupFeeReceipt{}, balanceErr
}
return ledger.CPBreakupFeeReceipt{
TransactionID: txRow.TransactionID,
CoinSpent: command.Amount,
CoinBalanceAfter: balance.AvailableAmount,
Balance: balance,
}, nil
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
if err != nil {
return ledger.CPBreakupFeeReceipt{}, err
}
if account.AvailableAmount < command.Amount {
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount - command.Amount
metadata := cpBreakupFeeMetadata{
AppCode: command.AppCode,
UserID: command.UserID,
RelationshipID: command.RelationshipID,
RelationType: command.RelationType,
Amount: command.Amount,
CoinBalanceAfter: balanceAfter,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCPBreakupFee, requestHash, command.RelationshipID, metadata, nowMs); err != nil {
return ledger.CPBreakupFeeReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil {
return ledger.CPBreakupFeeReceipt{}, err
}
balance := ledger.AssetBalance{
AppCode: command.AppCode,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableAmount: balanceAfter,
FrozenAmount: account.FrozenAmount,
Version: account.Version + 1,
UpdatedAtMs: nowMs,
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: -command.Amount,
FrozenDelta: 0,
AvailableAfter: balance.AvailableAmount,
FrozenAfter: balance.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.CPBreakupFeeReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs),
}); err != nil {
return ledger.CPBreakupFeeReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.CPBreakupFeeReceipt{}, err
}
return ledger.CPBreakupFeeReceipt{
TransactionID: transactionID,
CoinSpent: command.Amount,
CoinBalanceAfter: balance.AvailableAmount,
Balance: balance,
}, nil
}
// CreditTaskReward 在一个事务内完成任务奖励 COIN 入账、交易分录和 outbox 事件。
func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
if r == nil || r.db == nil {
return ledger.TaskRewardReceipt{}, 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.TaskRewardReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := taskRewardRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeTaskReward); err != nil || exists {
if err != nil || !exists {
return ledger.TaskRewardReceipt{}, err
}
return r.receiptForTaskRewardTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.TaskRewardReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount + command.Amount
metadata := taskRewardMetadata{
AppCode: command.AppCode,
TargetUserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
Amount: command.Amount,
TaskType: command.TaskType,
TaskID: command.TaskID,
CycleKey: command.CycleKey,
Reason: command.Reason,
BalanceAfter: balanceAfter,
GrantedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeTaskReward, requestHash, fmt.Sprintf("%s:%s", command.TaskID, command.CycleKey), metadata, nowMs); err != nil {
return ledger.TaskRewardReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
return ledger.TaskRewardReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: command.Amount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.TaskRewardReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
taskRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.TaskRewardReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.TaskRewardReceipt{}, err
}
return receiptFromTaskRewardMetadata(transactionID, metadata), nil
}
// CreditLuckyGiftReward 在同一事务里完成幸运礼物中奖 COIN 入账、交易分录和钱包 outbox。
func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) {
if r == nil || r.db == nil {
return ledger.LuckyGiftRewardReceipt{}, 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.LuckyGiftRewardReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := luckyGiftRewardRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeLuckyGiftReward); err != nil || exists {
if err != nil || !exists {
return ledger.LuckyGiftRewardReceipt{}, err
}
return r.receiptForLuckyGiftRewardTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount + command.Amount
metadata := luckyGiftRewardMetadata{
AppCode: command.AppCode,
TargetUserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
Amount: command.Amount,
DrawID: command.DrawID,
RoomID: command.RoomID,
VisibleRegionID: command.VisibleRegionID,
GiftID: command.GiftID,
PoolID: command.PoolID,
Reason: command.Reason,
BalanceAfter: balanceAfter,
GrantedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeLuckyGiftReward, requestHash, command.DrawID, metadata, nowMs); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: command.Amount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
luckyGiftRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil
}
// CreditRoomTurnoverReward 在同一事务里完成每周房间流水奖励 COIN 入账、交易分录和钱包 outbox。
func (r *Repository) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
if r == nil || r.db == nil {
return ledger.RoomTurnoverRewardReceipt{}, 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.RoomTurnoverRewardReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := roomTurnoverRewardRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeRoomTurnoverReward); err != nil || exists {
if err != nil || !exists {
return ledger.RoomTurnoverRewardReceipt{}, err
}
// command_id 相同且请求摘要一致说明是 activity-service 的重复发奖调用;直接返回原交易回执,不再改余额。
return r.receiptForRoomTurnoverRewardTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
// 锁定房主 COIN 账户后再计算余额,保证同一用户同时收到多笔奖励时 available_after 仍是线性账本结果。
account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.RoomTurnoverRewardReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount + command.Amount
// metadata 是交易、分录和 outbox 的共享事实快照;后续补发事件或幂等查询都从这里还原原始结算上下文。
metadata := roomTurnoverRewardMetadata{
AppCode: command.AppCode,
TargetUserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
Amount: command.Amount,
SettlementID: command.SettlementID,
RoomID: command.RoomID,
PeriodStartMS: command.PeriodStartMS,
PeriodEndMS: command.PeriodEndMS,
CoinSpent: command.CoinSpent,
TierID: command.TierID,
TierCode: command.TierCode,
Reason: command.Reason,
BalanceAfter: balanceAfter,
GrantedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRoomTurnoverReward, requestHash, command.SettlementID, metadata, nowMs); err != nil {
return ledger.RoomTurnoverRewardReceipt{}, err
}
// 交易、账户余额、账本分录和 outbox 必须在同一事务提交;任意一步失败都会回滚,避免出现发奖记录和余额不一致。
if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
return ledger.RoomTurnoverRewardReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: command.Amount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.RoomTurnoverRewardReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
roomTurnoverRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.RoomTurnoverRewardReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.RoomTurnoverRewardReceipt{}, err
}
return receiptFromRoomTurnoverRewardMetadata(transactionID, metadata), nil
}
// ApplyGameCoinChange 在同一事务内完成游戏 COIN 改账、分录和 outbox。
func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
if r == nil || r.db == nil {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
bizType, delta, err := gameBizTypeAndDelta(command.OpType, command.CoinAmount)
if err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, command.RequestHash, bizType, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.GameCoinChangeReceipt{}, err
}
receipt, receiptErr := r.receiptForGameTransaction(ctx, tx, txRow.TransactionID)
receipt.IdempotentReplay = true
return receipt, receiptErr
}
nowMs := time.Now().UnixMilli()
createIfMissing := delta > 0
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, createIfMissing, nowMs)
if err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if delta < 0 && account.AvailableAmount < -delta {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount + delta
metadata := gameCoinMetadata{
AppCode: command.AppCode,
UserID: command.UserID,
PlatformCode: command.PlatformCode,
GameID: command.GameID,
ProviderOrderID: command.ProviderOrderID,
ProviderRoundID: command.ProviderRoundID,
OpType: command.OpType,
AssetType: ledger.AssetCoin,
CoinAmount: command.CoinAmount,
AvailableDelta: delta,
RoomID: command.RoomID,
BalanceAfter: balanceAfter,
AppliedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, command.RequestHash, command.ProviderOrderID, metadata, nowMs); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, delta, 0, nowMs); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: delta,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
}); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
return receiptFromGameMetadata(transactionID, metadata, false), nil
}
// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账,并记录专用进货明细。
func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
if r == nil || r.db == nil {
return ledger.CoinSellerStockCreditReceipt{}, 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.CoinSellerStockCreditReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
bizType := coinSellerStockBizType(command.StockType)
if bizType == "" {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
}
requestHash := coinSellerStockRequestHash(command)
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizType, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.CoinSellerStockCreditReceipt{}, err
}
return r.receiptForCoinSellerStockTransaction(ctx, tx, txRow.TransactionID)
}
if command.PaymentRef != "" {
if duplicated, err := r.coinSellerStockPaymentRefExists(ctx, tx, command.StockType, command.PaymentRef); err != nil || duplicated {
if err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
}
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs)
if err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
balanceAfter, err := checkedAdd(account.AvailableAmount, command.CoinAmount)
if err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase
metadata := coinSellerStockMetadata{
AppCode: command.AppCode,
SellerUserID: command.SellerUserID,
SellerCountryID: command.SellerCountryID,
SellerRegionID: command.SellerRegionID,
StockType: command.StockType,
CoinAmount: command.CoinAmount,
PaidCurrencyCode: command.PaidCurrencyCode,
PaidAmountMicro: command.PaidAmountMicro,
PaymentRef: command.PaymentRef,
EvidenceRef: command.EvidenceRef,
OperatorUserID: command.OperatorUserID,
Reason: command.Reason,
AssetType: ledger.AssetCoinSellerCoin,
CountsAsSellerRecharge: countsAsSellerRecharge,
BalanceAfter: balanceAfter,
CreatedAtMS: nowMs,
}
externalRef := command.EvidenceRef
if command.PaymentRef != "" {
externalRef = command.PaymentRef
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, command.CoinAmount, 0, nowMs); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SellerUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: command.CoinAmount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, command.CommandID, metadata, nowMs); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
return receiptFromCoinSellerStockMetadata(transactionID, metadata), 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)
// 币商向用户转普通金币只计普通金币到账和充值用户,不计入用户 USDT/USD 充值金额。
rechargeUSDMinor := int64(0)
rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.TargetUserID, transactionID, command.Amount, rechargeUSDMinor, nowMs)
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,
TargetCountryID: command.TargetCountryID,
SellerRegionID: command.SellerRegionID,
TargetRegionID: command.TargetRegionID,
Amount: command.Amount,
Reason: command.Reason,
SellerAssetType: ledger.AssetCoinSellerCoin,
TargetAssetType: ledger.AssetCoin,
SellerBalanceAfter: sellerAfter,
TargetBalanceAfter: targetAfter,
RechargeSequence: rechargeSequence,
RechargeUSDMinor: rechargeUSDMinor,
RechargeCurrencyCode: "",
RechargePolicyID: 0,
RechargePolicyVersion: "",
RechargePolicyCoinAmount: 0,
RechargePolicyUSDMinorAmount: 0,
}
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, seller.Version+1, metadata, nowMs),
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, target.Version+1, 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
}
// ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间;默认只返回 active 区间。
func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
statusClause := "AND status = 'active'"
if includeDisabled {
statusClause = ""
}
query := fmt.Sprintf(`
SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ? %s
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC`, statusClause)
rows, err := r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID)
if err != nil {
return nil, err
}
defer rows.Close()
tiers := make([]ledger.CoinSellerSalaryExchangeRateTier, 0)
for rows.Next() {
var tier ledger.CoinSellerSalaryExchangeRateTier
if err := rows.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil {
return nil, err
}
tiers = append(tiers, tier)
}
if err := rows.Err(); err != nil {
return nil, err
}
return tiers, nil
}
// ExchangeSalaryToCoin 在同一事务里扣工资美元钱包、给当前用户普通 COIN 入账,保证两边余额和幂等一起落库。
func (r *Repository) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) {
if r == nil || r.db == nil {
return ledger.SalaryExchangeReceipt{}, 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.SalaryExchangeReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := salaryExchangeRequestHash(command)
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryExchangeToCoin, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.SalaryExchangeReceipt{}, err
}
return r.receiptForSalaryExchangeTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, ledger.SalaryExchangeCoinPerUSD)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
salaryAccount, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if salaryAccount.AvailableAmount < command.SalaryUSDMinor {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
coinAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
salaryAfter := salaryAccount.AvailableAmount - command.SalaryUSDMinor
coinAfter, err := checkedAdd(coinAccount.AvailableAmount, coinAmount)
if err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
metadata := salaryExchangeMetadata{
AppCode: command.AppCode,
UserID: command.UserID,
SalaryAssetType: command.SalaryAssetType,
CoinAssetType: ledger.AssetCoin,
SalaryUSDMinor: command.SalaryUSDMinor,
CoinPerUSD: ledger.SalaryExchangeCoinPerUSD,
CoinAmount: coinAmount,
SalaryBalanceAfter: salaryAfter,
CoinBalanceAfter: coinAfter,
Reason: command.Reason,
CreatedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryExchangeToCoin, requestHash, fmt.Sprintf("salary_exchange:%d", command.UserID), metadata, nowMs); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, salaryAccount, -command.SalaryUSDMinor, 0, nowMs); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: command.SalaryAssetType,
AvailableDelta: -command.SalaryUSDMinor,
FrozenDelta: 0,
AvailableAfter: salaryAfter,
FrozenAfter: salaryAccount.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, coinAccount, coinAmount, 0, nowMs); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: coinAmount,
FrozenDelta: 0,
AvailableAfter: coinAfter,
FrozenAfter: coinAccount.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, salaryAfter, salaryAccount.FrozenAmount, salaryAccount.Version+1, metadata, nowMs),
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, coinAmount, 0, coinAfter, coinAccount.FrozenAmount, coinAccount.Version+1, metadata, nowMs),
}); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil
}
// TransferSalaryToCoinSeller 在同一事务里扣来源工资钱包、按当前区域配置给币商专用金币库存入账。
func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) {
if r == nil || r.db == nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, 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.SalaryTransferToCoinSellerReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
requestHash := salaryTransferToCoinSellerRequestHash(command)
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryTransferToCoinSeller, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
return r.receiptForSalaryTransferToCoinSellerTransaction(ctx, tx, txRow.TransactionID)
}
nowMs := time.Now().UnixMilli()
rate, err := r.resolveCoinSellerSalaryExchangeRateTier(ctx, tx, command.RegionID, command.SalaryUSDMinor)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, rate.CoinPerUSD)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
source, err := r.lockAccount(ctx, tx, command.SourceUserID, command.SalaryAssetType, false, nowMs)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if source.AvailableAmount < command.SalaryUSDMinor {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
sourceAfter := source.AvailableAmount - command.SalaryUSDMinor
sellerAfter, err := checkedAdd(seller.AvailableAmount, coinAmount)
if err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
metadata := salaryTransferToCoinSellerMetadata{
AppCode: command.AppCode,
SourceUserID: command.SourceUserID,
SellerUserID: command.SellerUserID,
RegionID: command.RegionID,
SalaryAssetType: command.SalaryAssetType,
SellerAssetType: ledger.AssetCoinSellerCoin,
SalaryUSDMinor: command.SalaryUSDMinor,
CoinPerUSD: rate.CoinPerUSD,
CoinAmount: coinAmount,
RateMinUSDMinor: rate.MinUSDMinor,
RateMaxUSDMinor: rate.MaxUSDMinor,
SourceSalaryBalanceAfter: sourceAfter,
SellerBalanceAfter: sellerAfter,
Reason: command.Reason,
CreatedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryTransferToCoinSeller, requestHash, fmt.Sprintf("salary_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMs); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, source, -command.SalaryUSDMinor, 0, nowMs); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SourceUserID,
AssetType: command.SalaryAssetType,
AvailableDelta: -command.SalaryUSDMinor,
FrozenDelta: 0,
AvailableAfter: sourceAfter,
FrozenAfter: source.FrozenAmount,
CounterpartyUserID: command.SellerUserID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, seller, coinAmount, 0, nowMs); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SellerUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: coinAmount,
FrozenDelta: 0,
AvailableAfter: sellerAfter,
FrozenAfter: seller.FrozenAmount,
CounterpartyUserID: command.SourceUserID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs),
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs),
}); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
return receiptFromSalaryTransferToCoinSellerMetadata(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) {
return r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizType, xerr.LedgerConflict)
}
func (r *Repository) lookupTransactionWithConflictCode(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string, conflictCode xerr.Code) (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(conflictCode, "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
ChargeAssetType string
CoinPrice int64
// GiftPointAmount 是 wallet_gift_prices 的历史列;送礼结算只按 CoinPrice 和比例计算,不再读取它做收益。
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, COALESCE(charge_asset_type, 'COIN'), 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, COALESCE(charge_asset_type, 'COIN'), 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.ChargeAssetType, &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.HeatValue < 0 {
return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid")
}
price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
return price, nil
}
type giftDiamondRatioSnapshot struct {
Percent string
PPM int64
}
func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
giftTypeCode = strings.TrimSpace(giftTypeCode)
if giftTypeCode == "" {
giftTypeCode = "normal"
}
regions := []int64{regionID}
if regionID != 0 {
regions = append(regions, 0)
}
for _, regionID := range regions {
ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode)
if err != nil {
return giftDiamondRatioSnapshot{}, 0, err
}
if exists {
return ratio, regionID, nil
}
}
return defaultGiftDiamondRatio(giftTypeCode), 0, nil
}
func (r *Repository) resolveGlobalGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
// 送礼运行链路只认后台“全局默认”行,避免房间区域或送礼人区域把贡献值和主播钻石算成不同口径。
return r.resolveGiftDiamondRatio(ctx, tx, appCode, 0, giftTypeCode)
}
func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) {
var percent string
var ppm int64
err := tx.QueryRowContext(ctx, `
SELECT CAST(ratio_percent AS CHAR), CAST(ROUND(ratio_percent * 10000) AS SIGNED)
FROM gift_diamond_ratio_configs
WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active'
LIMIT 1`,
appcode.Normalize(appCode), regionID, giftTypeCode,
).Scan(&percent, &ppm)
if errors.Is(err, sql.ErrNoRows) {
return giftDiamondRatioSnapshot{}, false, nil
}
if err != nil {
return giftDiamondRatioSnapshot{}, false, err
}
if ppm < 0 || ppm > 1_000_000 {
return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid")
}
percent = strings.TrimSpace(percent)
if percent == "" {
percent = "100.00"
}
return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil
}
func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) {
if chargeAmount < 0 || ratioPPM < 0 {
return 0, xerr.New(xerr.InvalidArgument, "gift diamond amount is invalid")
}
product, err := checkedMul(chargeAmount, ratioPPM)
if err != nil {
return 0, err
}
return product / 1_000_000, nil
}
func defaultGiftDiamondRatio(giftTypeCode string) giftDiamondRatioSnapshot {
switch strings.TrimSpace(giftTypeCode) {
case "lucky":
return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000}
case "super_lucky":
return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000}
default:
return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000}
}
}
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
}
// 金币必须完整到账;充值 USD 统计只能落到最小货币单位,不能表示的尾差向下取整。
return numerator / policy.CoinAmount, nil
}
func (r *Repository) resolveCoinSellerSalaryExchangeRateTier(ctx context.Context, tx *sql.Tx, regionID int64, salaryUSDMinor int64) (ledger.CoinSellerSalaryExchangeRateTier, error) {
row := tx.QueryRowContext(ctx,
`SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ? AND status = 'active'
AND min_usd_minor <= ? AND max_usd_minor >= ?
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC
LIMIT 1
FOR UPDATE`,
appcode.FromContext(ctx),
regionID,
salaryUSDMinor,
salaryUSDMinor,
)
var tier ledger.CoinSellerSalaryExchangeRateTier
if err := row.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.NotFound, "exchange rate not configured")
}
return ledger.CoinSellerSalaryExchangeRateTier{}, err
}
if tier.MinUSDMinor <= 0 || tier.MaxUSDMinor < tier.MinUSDMinor || tier.CoinPerUSD <= 0 || tier.CoinPerUSD%100 != 0 {
// 工资以美分入账coin_per_usd 必须能被 100 整除,避免转账金币出现隐式四舍五入。
return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.Internal, "coin seller salary exchange rate is invalid")
}
return tier, nil
}
func calculateSalaryCoinAmount(salaryUSDMinor int64, coinPerUSD int64) (int64, error) {
numerator, err := checkedMul(salaryUSDMinor, coinPerUSD)
if err != nil {
return 0, err
}
if numerator%100 != 0 {
return 0, xerr.New(xerr.InvalidArgument, "salary amount does not match exchange rate")
}
return numerator / 100, 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, recharge_sequence, 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.RechargeSequence,
metadata.SellerUserID,
metadata.SellerRegionID,
metadata.TargetRegionID,
metadata.RechargePolicyID,
metadata.RechargePolicyVersion,
metadata.RechargeCurrencyCode,
metadata.Amount,
metadata.RechargeUSDMinor,
metadata.Amount,
metadata.RechargePolicyUSDMinorAmount,
nowMs,
)
return err
}
func (r *Repository) reserveUserRechargeSequence(ctx context.Context, tx *sql.Tx, appCode string, userID int64, transactionID string, coinAmount int64, usdMinorAmount int64, nowMs int64) (int64, error) {
normalizedApp := normalizedEntryAppCode(ctx, appCode)
var count int64
err := tx.QueryRowContext(ctx, `
SELECT recharge_count
FROM wallet_user_recharge_stats
WHERE app_code = ? AND user_id = ?
FOR UPDATE`,
normalizedApp, userID,
).Scan(&count)
if errors.Is(err, sql.ErrNoRows) {
_, err = tx.ExecContext(ctx, `
INSERT INTO wallet_user_recharge_stats (
app_code, user_id, recharge_count, first_transaction_id, first_recharged_at_ms,
total_coin_amount, total_usd_minor_amount, created_at_ms, updated_at_ms
) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`,
normalizedApp, userID, transactionID, nowMs, coinAmount, usdMinorAmount, nowMs, nowMs,
)
if err != nil {
return 0, err
}
return 1, nil
}
if err != nil {
return 0, err
}
next := count + 1
_, err = tx.ExecContext(ctx, `
UPDATE wallet_user_recharge_stats
SET recharge_count = ?,
total_coin_amount = total_coin_amount + ?,
total_usd_minor_amount = total_usd_minor_amount + ?,
updated_at_ms = ?
WHERE app_code = ? AND user_id = ?`,
next, coinAmount, usdMinorAmount, nowMs, normalizedApp, userID,
)
if err != nil {
return 0, err
}
return next, nil
}
func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return err
}
var paymentRef any
if metadata.PaymentRef != "" {
paymentRef = metadata.PaymentRef
}
_, err = tx.ExecContext(ctx,
`INSERT INTO coin_seller_stock_records (
app_code, stock_id, transaction_id, command_id, seller_user_id, stock_type,
counts_as_seller_recharge, coin_amount, paid_currency_code, paid_amount_micro,
payment_ref, evidence_ref, operator_user_id, reason, balance_after, metadata_json, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
normalizedEntryAppCode(ctx, metadata.AppCode),
coinSellerStockID(metadata.AppCode, commandID),
transactionID,
commandID,
metadata.SellerUserID,
metadata.StockType,
metadata.CountsAsSellerRecharge,
metadata.CoinAmount,
metadata.PaidCurrencyCode,
metadata.PaidAmountMicro,
paymentRef,
metadata.EvidenceRef,
metadata.OperatorUserID,
metadata.Reason,
metadata.BalanceAfter,
string(metadataJSON),
nowMs,
)
if err != nil && isMySQLDuplicateError(err) && metadata.PaymentRef != "" {
return xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
}
return err
}
func (r *Repository) coinSellerStockPaymentRefExists(ctx context.Context, tx *sql.Tx, stockType string, paymentRef string) (bool, error) {
var stockID string
err := tx.QueryRowContext(ctx,
`SELECT stock_id
FROM coin_seller_stock_records
WHERE app_code = ? AND stock_type = ? AND payment_ref = ?
LIMIT 1`,
appcode.FromContext(ctx),
stockType,
paymentRef,
).Scan(&stockID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return true, nil
}
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
}
func (r *Repository) creditHostPeriodDiamonds(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, nowMs int64) (int64, int64, error) {
// host_period_diamond_accounts 是工资引擎读取的投影表,不是可提现钱包资产;只有主播身份收礼会写入。
if metadata.HostPeriodDiamondAdded <= 0 {
return 0, 0, nil
}
after, version, exists, err := r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey)
if err != nil {
return 0, 0, err
}
if !exists {
// 周期账户只在主播首次收礼时创建;工资结算任务按 cycle_key 扫描,不依赖通用 wallet_accounts。
// 首笔送礼写入当时区域和代理;同周期后续收礼会刷新快照,结算以周期账户最终快照为准。
after = metadata.HostPeriodDiamondAdded
version = 1
_, err = tx.ExecContext(ctx,
`INSERT INTO host_period_diamond_accounts (
app_code, user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total,
version, first_gift_at_ms, last_gift_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx),
metadata.TargetUserID,
metadata.HostPeriodCycleKey,
metadata.TargetHostRegionID,
metadata.TargetAgencyOwnerUserID,
after,
metadata.HostPeriodDiamondAdded,
version,
nowMs,
nowMs,
nowMs,
nowMs,
)
if err != nil && isMySQLDuplicateError(err) {
// 并发首笔送礼可能同时发现账户不存在;退回锁读更新路径,保证不丢钻石。
after, version, exists, err = r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey)
}
if err != nil {
return 0, 0, err
}
if !exists {
if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil {
return 0, 0, err
}
return after, version, nil
}
}
after, err = checkedAdd(after, metadata.HostPeriodDiamondAdded)
if err != nil {
return 0, 0, err
}
version++
// 已存在账户时更新累计钻石,同时刷新区域/代理快照为最近一次收礼状态;这符合“当月当前归属”口径。
result, err := tx.ExecContext(ctx,
`UPDATE host_period_diamond_accounts
SET region_id = ?, agency_owner_user_id = ?, total_diamonds = ?, gift_diamond_total = gift_diamond_total + ?,
version = ?, last_gift_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND cycle_key = ?`,
metadata.TargetHostRegionID,
metadata.TargetAgencyOwnerUserID,
after,
metadata.HostPeriodDiamondAdded,
version,
nowMs,
nowMs,
appcode.FromContext(ctx),
metadata.TargetUserID,
metadata.HostPeriodCycleKey,
)
if err != nil {
return 0, 0, err
}
rows, err := result.RowsAffected()
if err != nil {
return 0, 0, err
}
if rows != 1 {
return 0, 0, xerr.New(xerr.LedgerConflict, "host period diamond account version conflict")
}
if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil {
return 0, 0, err
}
return after, version, nil
}
func (r *Repository) lockHostPeriodDiamondAccount(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (int64, int64, bool, error) {
// FOR UPDATE 串行化同主播同周期的并发送礼,保证 total_diamonds 不会被覆盖丢失。
row := tx.QueryRowContext(ctx,
`SELECT total_diamonds, version
FROM host_period_diamond_accounts
WHERE app_code = ? AND user_id = ? AND cycle_key = ?
FOR UPDATE`,
appcode.FromContext(ctx),
userID,
cycleKey,
)
var totalDiamonds int64
var version int64
if err := row.Scan(&totalDiamonds, &version); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, false, nil
}
return 0, 0, false, err
}
return totalDiamonds, version, true, nil
}
func (r *Repository) insertHostPeriodDiamondEntry(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, diamondAfter int64, nowMs int64) error {
// 明细表记录每一笔送礼带来的工资钻石,后续核对周期账户累计值时不需要解析钱包 metadata。
_, err := tx.ExecContext(ctx,
`INSERT INTO host_period_diamond_entries (
app_code, transaction_id, command_id, user_id, cycle_key, region_id, agency_owner_user_id,
diamond_delta, diamond_after, gift_charge_asset_type, gift_charge_amount,
gift_id, gift_count, sender_user_id, room_id, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx),
transactionID,
commandID,
metadata.TargetUserID,
metadata.HostPeriodCycleKey,
metadata.TargetHostRegionID,
metadata.TargetAgencyOwnerUserID,
metadata.HostPeriodDiamondAdded,
diamondAfter,
metadata.ChargeAssetType,
metadata.ChargeAmount,
metadata.GiftID,
metadata.GiftCount,
metadata.SenderUserID,
metadata.RoomID,
nowMs,
)
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
}
// ClaimPendingWalletOutbox atomically claims committed wallet facts for MQ fanout.
func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]WalletOutboxRecord, error) {
return r.ClaimPendingWalletOutboxFiltered(ctx, workerID, limit, lockUntilMS, WalletOutboxClaimFilter{})
}
// ClaimPendingWalletOutboxFiltered atomically claims committed wallet facts for one MQ fanout lane.
func (r *Repository) ClaimPendingWalletOutboxFiltered(ctx context.Context, workerID string, limit int, lockUntilMS int64, filter WalletOutboxClaimFilter) ([]WalletOutboxRecord, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return nil, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
nowMS := time.Now().UTC().UnixMilli()
if lockUntilMS <= nowMS {
lockUntilMS = time.Now().UTC().Add(30 * time.Second).UnixMilli()
}
appCode := appcode.FromContext(ctx)
eventFilterSQL, eventFilterArgs := walletOutboxEventFilterSQL(filter)
pendingIndex, claimIndex := walletOutboxClaimIndexes(filter)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
records := make([]WalletOutboxRecord, 0, limit)
claimBranches := []struct {
query string
args []any
}{
{
query: walletOutboxClaimQuery(pendingIndex, `
AND next_retry_at_ms IS NULL
AND lock_until_ms IS NULL`, eventFilterSQL),
args: append([]any{appCode, outboxStatusPending}, eventFilterArgs...),
},
{
query: walletOutboxClaimQuery(pendingIndex, `
AND next_retry_at_ms <= ?
AND lock_until_ms IS NULL`, eventFilterSQL),
args: append([]any{appCode, outboxStatusRetryable, nowMS}, eventFilterArgs...),
},
{
query: walletOutboxClaimQuery(claimIndex, `
AND lock_until_ms <= ?`, eventFilterSQL),
args: append([]any{appCode, outboxStatusDelivering, nowMS}, eventFilterArgs...),
},
}
for _, branch := range claimBranches {
remaining := limit - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := queryWalletOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
return nil, err
}
records = append(records, branchRecords...)
}
for _, record := range records {
if _, err := tx.ExecContext(ctx, `
UPDATE wallet_outbox
SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outboxStatusDelivering,
workerID,
lockUntilMS,
nowMS,
record.AppCode,
record.EventID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}
func walletOutboxClaimQuery(indexName string, statusCondition string, eventFilterSQL string) string {
return `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
FROM wallet_outbox FORCE INDEX (` + indexName + `)
WHERE app_code = ? AND status = ?` + statusCondition + eventFilterSQL + `
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`
}
func walletOutboxEventFilterSQL(filter WalletOutboxClaimFilter) (string, []any) {
include := normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)
if len(include) > 0 {
return " AND event_type IN (" + walletOutboxPlaceholders(len(include)) + ")", walletOutboxEventArgs(include)
}
exclude := normalizeWalletOutboxEventTypes(filter.ExcludeEventTypes)
if len(exclude) > 0 {
return " AND event_type NOT IN (" + walletOutboxPlaceholders(len(exclude)) + ")", walletOutboxEventArgs(exclude)
}
return "", nil
}
func walletOutboxClaimIndexes(filter WalletOutboxClaimFilter) (string, string) {
if len(normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)) > 0 {
return "idx_wallet_outbox_event_pending", "idx_wallet_outbox_event_claim"
}
return "idx_wallet_outbox_pending", "idx_wallet_outbox_claim"
}
func normalizeWalletOutboxEventTypes(values []string) []string {
normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
return normalized
}
func walletOutboxPlaceholders(count int) string {
if count <= 0 {
return ""
}
placeholders := make([]string, count)
for index := range placeholders {
placeholders[index] = "?"
}
return strings.Join(placeholders, ",")
}
func walletOutboxEventArgs(values []string) []any {
args := make([]any, 0, len(values))
for _, value := range values {
args = append(args, value)
}
return args
}
func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]WalletOutboxRecord, 0)
for rows.Next() {
var record WalletOutboxRecord
if err := rows.Scan(
&record.AppCode,
&record.EventID,
&record.EventType,
&record.TransactionID,
&record.CommandID,
&record.UserID,
&record.AssetType,
&record.AvailableDelta,
&record.FrozenDelta,
&record.PayloadJSON,
&record.CreatedAtMS,
&record.RetryCount,
&record.LastError,
&record.WorkerID,
&record.LockUntilMS,
); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// MarkWalletOutboxDelivered marks one wallet fact as accepted by the MQ broker.
func (r *Repository) MarkWalletOutboxDelivered(ctx context.Context, eventID string) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE wallet_outbox
SET status = ?, worker_id = '', lock_until_ms = NULL, next_retry_at_ms = NULL, last_error = NULL, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outboxStatusDelivered,
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
eventID,
)
return err
}
// MarkWalletOutboxRetryable releases one failed MQ publish with a bounded retry schedule.
func (r *Repository) MarkWalletOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE wallet_outbox
SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outboxStatusRetryable,
nextRetryAtMS,
trimWalletOutboxError(lastErr),
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
eventID,
)
return err
}
// MarkWalletOutboxDead stops retrying a poison wallet fact after the configured retry ceiling.
func (r *Repository) MarkWalletOutboxDead(ctx context.Context, eventID string, lastErr string) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE wallet_outbox
SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outboxStatusFailed,
trimWalletOutboxError(lastErr),
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
eventID,
)
return err
}
func trimWalletOutboxError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 1024 {
return value[:1024]
}
return value
}
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"`
GiftIconURL string `json:"gift_icon_url"`
GiftAnimationURL string `json:"gift_animation_url"`
GiftEffectTypes []string `json:"gift_effect_types"`
ResourceID int64 `json:"resource_id"`
ResourceSnapshot string `json:"resource_snapshot_json"`
GiftTypeCode string `json:"gift_type_code"`
CPRelationType string `json:"cp_relation_type"`
PresentationJSON string `json:"presentation_json"`
SortOrder int32 `json:"sort_order"`
GiftCount int32 `json:"gift_count"`
PriceVersion string `json:"price_version"`
CoinPrice int64 `json:"coin_price"`
// GiftPointAmount 是历史礼物积分单价字段,新送礼固定写 0业务统计只读真实扣费和热度。
GiftPointAmount int64 `json:"gift_point_amount"`
HeatUnitValue int64 `json:"heat_unit_value"`
ChargeAssetType string `json:"charge_asset_type"`
ChargeAmount int64 `json:"charge_amount"`
CoinSpent int64 `json:"coin_spent"`
// GiftPointAdded 是历史收礼积分字段,新送礼固定写 0保留 JSON 字段只为旧事件解析兼容。
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"`
SenderRegionID int64 `json:"sender_region_id"`
TargetUserID int64 `json:"target_user_id"`
TargetIsHost bool `json:"target_is_host"`
TargetHostRegionID int64 `json:"target_host_region_id"`
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
HostPeriodCycleKey string `json:"host_period_cycle_key"`
RoomID string `json:"room_id"`
RoomRegionID int64 `json:"room_region_id"`
RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"`
RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_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 cpBreakupFeeMetadata struct {
AppCode string `json:"app_code"`
UserID int64 `json:"user_id"`
RelationshipID string `json:"relationship_id"`
RelationType string `json:"relation_type"`
Amount int64 `json:"amount"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
}
type taskRewardMetadata struct {
AppCode string `json:"app_code"`
TargetUserID int64 `json:"target_user_id"`
AssetType string `json:"asset_type"`
Amount int64 `json:"amount"`
TaskType string `json:"task_type"`
TaskID string `json:"task_id"`
CycleKey string `json:"cycle_key"`
Reason string `json:"reason"`
BalanceAfter int64 `json:"balance_after"`
GrantedAtMS int64 `json:"granted_at_ms"`
}
type luckyGiftRewardMetadata struct {
AppCode string `json:"app_code"`
TargetUserID int64 `json:"target_user_id"`
AssetType string `json:"asset_type"`
Amount int64 `json:"amount"`
DrawID string `json:"draw_id"`
RoomID string `json:"room_id"`
VisibleRegionID int64 `json:"visible_region_id"`
GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"`
Reason string `json:"reason"`
BalanceAfter int64 `json:"balance_after"`
GrantedAtMS int64 `json:"granted_at_ms"`
}
type roomTurnoverRewardMetadata struct {
AppCode string `json:"app_code"`
TargetUserID int64 `json:"target_user_id"`
AssetType string `json:"asset_type"`
Amount int64 `json:"amount"`
SettlementID string `json:"settlement_id"`
RoomID string `json:"room_id"`
PeriodStartMS int64 `json:"period_start_ms"`
PeriodEndMS int64 `json:"period_end_ms"`
CoinSpent int64 `json:"coin_spent"`
TierID int64 `json:"tier_id"`
TierCode string `json:"tier_code"`
Reason string `json:"reason"`
BalanceAfter int64 `json:"balance_after"`
GrantedAtMS int64 `json:"granted_at_ms"`
}
type gameCoinMetadata struct {
AppCode string `json:"app_code"`
UserID int64 `json:"user_id"`
PlatformCode string `json:"platform_code"`
GameID string `json:"game_id"`
ProviderOrderID string `json:"provider_order_id"`
ProviderRoundID string `json:"provider_round_id"`
OpType string `json:"op_type"`
AssetType string `json:"asset_type"`
CoinAmount int64 `json:"coin_amount"`
AvailableDelta int64 `json:"available_delta"`
RoomID string `json:"room_id"`
BalanceAfter int64 `json:"balance_after"`
AppliedAtMS int64 `json:"applied_at_ms"`
}
type coinSellerTransferMetadata struct {
AppCode string `json:"app_code"`
PaymentOrderID string `json:"payment_order_id,omitempty"`
ProviderCode string `json:"provider,omitempty"`
PaymentMethodID int64 `json:"payment_method_id,omitempty"`
SellerUserID int64 `json:"seller_user_id"`
TargetUserID int64 `json:"target_user_id"`
TargetCountryID int64 `json:"target_country_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"`
RechargeSequence int64 `json:"recharge_sequence"`
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"`
RechargeType string `json:"recharge_type,omitempty"`
}
type salaryExchangeMetadata struct {
AppCode string `json:"app_code"`
UserID int64 `json:"user_id"`
SalaryAssetType string `json:"salary_asset_type"`
CoinAssetType string `json:"coin_asset_type"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
CoinPerUSD int64 `json:"coin_per_usd"`
CoinAmount int64 `json:"coin_amount"`
SalaryBalanceAfter int64 `json:"salary_balance_after"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type salaryTransferToCoinSellerMetadata struct {
AppCode string `json:"app_code"`
SourceUserID int64 `json:"source_user_id"`
SellerUserID int64 `json:"seller_user_id"`
RegionID int64 `json:"region_id"`
SalaryAssetType string `json:"salary_asset_type"`
SellerAssetType string `json:"seller_asset_type"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
CoinPerUSD int64 `json:"coin_per_usd"`
CoinAmount int64 `json:"coin_amount"`
RateMinUSDMinor int64 `json:"rate_min_usd_minor"`
RateMaxUSDMinor int64 `json:"rate_max_usd_minor"`
SourceSalaryBalanceAfter int64 `json:"source_salary_balance_after"`
SellerBalanceAfter int64 `json:"seller_balance_after"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type coinSellerStockMetadata struct {
AppCode string `json:"app_code"`
PaymentOrderID string `json:"payment_order_id,omitempty"`
ProviderCode string `json:"provider,omitempty"`
PaymentMethodID int64 `json:"payment_method_id,omitempty"`
SellerUserID int64 `json:"seller_user_id"`
SellerCountryID int64 `json:"seller_country_id"`
SellerRegionID int64 `json:"seller_region_id"`
StockType string `json:"stock_type"`
CoinAmount int64 `json:"coin_amount"`
PaidCurrencyCode string `json:"paid_currency_code"`
PaidAmountMicro int64 `json:"paid_amount_micro"`
PaymentRef string `json:"payment_ref"`
EvidenceRef string `json:"evidence_ref"`
OperatorUserID int64 `json:"operator_user_id"`
Reason string `json:"reason"`
AssetType string `json:"asset_type"`
CountsAsSellerRecharge bool `json:"counts_as_seller_recharge"`
BalanceAfter int64 `json:"balance_after"`
CreatedAtMS int64 `json:"created_at_ms"`
}
func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
chargeAmount := metadata.ChargeAmount
if chargeAmount == 0 {
chargeAmount = metadata.CoinSpent
}
return ledger.Receipt{
BillingReceiptID: metadata.BillingReceipt,
TransactionID: transactionID,
CoinSpent: metadata.CoinSpent,
ChargeAssetType: chargeAssetType,
ChargeAmount: chargeAmount,
GiftPointAdded: 0,
HeatValue: metadata.HeatValue,
GiftTypeCode: metadata.GiftTypeCode,
CPRelationType: metadata.CPRelationType,
GiftName: metadata.GiftName,
GiftIconURL: metadata.GiftIconURL,
GiftAnimationURL: metadata.GiftAnimationURL,
GiftEffectTypes: normalizeGiftEffectTypes(metadata.GiftEffectTypes),
PriceVersion: metadata.PriceVersion,
BalanceAfter: metadata.BalanceAfter,
HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded,
HostPeriodCycleKey: metadata.HostPeriodCycleKey,
}
}
func giftDisplayIconURL(resource resourcedomain.Resource) string {
// preview_url 是面板和 IM 的首选静态图;旧资源没有 preview 时回退 asset_url避免关系广播缺图。
if value := strings.TrimSpace(resource.PreviewURL); value != "" {
return value
}
return strings.TrimSpace(resource.AssetURL)
}
func (r *Repository) receiptForTaskRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.TaskRewardReceipt, 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.TaskRewardReceipt{}, err
}
var metadata taskRewardMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.TaskRewardReceipt{}, err
}
return receiptFromTaskRewardMetadata(transactionID, metadata), nil
}
func receiptFromTaskRewardMetadata(transactionID string, metadata taskRewardMetadata) ledger.TaskRewardReceipt {
return ledger.TaskRewardReceipt{
TransactionID: transactionID,
Balance: ledger.AssetBalance{
AppCode: metadata.AppCode,
UserID: metadata.TargetUserID,
AssetType: metadata.AssetType,
AvailableAmount: metadata.BalanceAfter,
},
Amount: metadata.Amount,
GrantedAtMS: metadata.GrantedAtMS,
}
}
func (r *Repository) receiptForLuckyGiftRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.LuckyGiftRewardReceipt, 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.LuckyGiftRewardReceipt{}, err
}
var metadata luckyGiftRewardMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.LuckyGiftRewardReceipt{}, err
}
return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil
}
func receiptFromLuckyGiftRewardMetadata(transactionID string, metadata luckyGiftRewardMetadata) ledger.LuckyGiftRewardReceipt {
return ledger.LuckyGiftRewardReceipt{
TransactionID: transactionID,
Balance: ledger.AssetBalance{
AppCode: metadata.AppCode,
UserID: metadata.TargetUserID,
AssetType: metadata.AssetType,
AvailableAmount: metadata.BalanceAfter,
},
Amount: metadata.Amount,
GrantedAtMS: metadata.GrantedAtMS,
}
}
func (r *Repository) receiptForRoomTurnoverRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RoomTurnoverRewardReceipt, 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.RoomTurnoverRewardReceipt{}, err
}
var metadata roomTurnoverRewardMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.RoomTurnoverRewardReceipt{}, err
}
return receiptFromRoomTurnoverRewardMetadata(transactionID, metadata), nil
}
func receiptFromRoomTurnoverRewardMetadata(transactionID string, metadata roomTurnoverRewardMetadata) ledger.RoomTurnoverRewardReceipt {
return ledger.RoomTurnoverRewardReceipt{
TransactionID: transactionID,
Balance: ledger.AssetBalance{
AppCode: metadata.AppCode,
UserID: metadata.TargetUserID,
AssetType: metadata.AssetType,
AvailableAmount: metadata.BalanceAfter,
},
Amount: metadata.Amount,
GrantedAtMS: metadata.GrantedAtMS,
}
}
func (r *Repository) receiptForGameTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.GameCoinChangeReceipt, 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.GameCoinChangeReceipt{}, err
}
var metadata gameCoinMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
return receiptFromGameMetadata(transactionID, metadata, false), nil
}
func receiptFromGameMetadata(transactionID string, metadata gameCoinMetadata, idempotentReplay bool) ledger.GameCoinChangeReceipt {
return ledger.GameCoinChangeReceipt{
TransactionID: transactionID,
BalanceAfter: metadata.BalanceAfter,
IdempotentReplay: idempotentReplay,
}
}
func (r *Repository) receiptForCoinSellerStockTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerStockCreditReceipt, error) {
row := tx.QueryRowContext(ctx,
`SELECT seller_user_id, stock_type, coin_amount, paid_currency_code, paid_amount_micro,
counts_as_seller_recharge, balance_after, metadata_json, created_at_ms
FROM coin_seller_stock_records
WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx),
transactionID,
)
var receipt ledger.CoinSellerStockCreditReceipt
var metadataJSON string
if err := row.Scan(
&receipt.SellerUserID,
&receipt.StockType,
&receipt.CoinAmount,
&receipt.PaidCurrencyCode,
&receipt.PaidAmountMicro,
&receipt.CountsAsSellerRecharge,
&receipt.BalanceAfter,
&metadataJSON,
&receipt.CreatedAtMS,
); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if metadataJSON != "" {
var metadata coinSellerStockMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err == nil {
receipt.SellerCountryID = metadata.SellerCountryID
receipt.SellerRegionID = metadata.SellerRegionID
}
}
receipt.TransactionID = transactionID
return receipt, nil
}
func receiptFromCoinSellerStockMetadata(transactionID string, metadata coinSellerStockMetadata) ledger.CoinSellerStockCreditReceipt {
return ledger.CoinSellerStockCreditReceipt{
TransactionID: transactionID,
SellerUserID: metadata.SellerUserID,
SellerCountryID: metadata.SellerCountryID,
SellerRegionID: metadata.SellerRegionID,
StockType: metadata.StockType,
CoinAmount: metadata.CoinAmount,
PaidCurrencyCode: metadata.PaidCurrencyCode,
PaidAmountMicro: metadata.PaidAmountMicro,
CountsAsSellerRecharge: metadata.CountsAsSellerRecharge,
BalanceAfter: metadata.BalanceAfter,
CreatedAtMS: metadata.CreatedAtMS,
}
}
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,
RechargeSequence: metadata.RechargeSequence,
RechargeUSDMinor: metadata.RechargeUSDMinor,
RechargeCurrencyCode: metadata.RechargeCurrencyCode,
RechargePolicyID: metadata.RechargePolicyID,
RechargePolicyVersion: metadata.RechargePolicyVersion,
RechargePolicyCoinAmount: metadata.RechargePolicyCoinAmount,
RechargePolicyUSDMinorUnit: metadata.RechargePolicyUSDMinorAmount,
}
}
func (r *Repository) receiptForSalaryExchangeTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryExchangeReceipt, 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.SalaryExchangeReceipt{}, err
}
var metadata salaryExchangeMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.SalaryExchangeReceipt{}, err
}
return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil
}
func receiptFromSalaryExchangeMetadata(transactionID string, metadata salaryExchangeMetadata) ledger.SalaryExchangeReceipt {
return ledger.SalaryExchangeReceipt{
TransactionID: transactionID,
UserID: metadata.UserID,
SalaryAssetType: metadata.SalaryAssetType,
SalaryBalanceAfter: metadata.SalaryBalanceAfter,
CoinBalanceAfter: metadata.CoinBalanceAfter,
SalaryUSDMinor: metadata.SalaryUSDMinor,
CoinAmount: metadata.CoinAmount,
CoinPerUSD: metadata.CoinPerUSD,
CreatedAtMS: metadata.CreatedAtMS,
}
}
func (r *Repository) receiptForSalaryTransferToCoinSellerTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.SalaryTransferToCoinSellerReceipt, 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.SalaryTransferToCoinSellerReceipt{}, err
}
var metadata salaryTransferToCoinSellerMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
return receiptFromSalaryTransferToCoinSellerMetadata(transactionID, metadata), nil
}
func receiptFromSalaryTransferToCoinSellerMetadata(transactionID string, metadata salaryTransferToCoinSellerMetadata) ledger.SalaryTransferToCoinSellerReceipt {
return ledger.SalaryTransferToCoinSellerReceipt{
TransactionID: transactionID,
SourceUserID: metadata.SourceUserID,
SellerUserID: metadata.SellerUserID,
SalaryAssetType: metadata.SalaryAssetType,
SourceSalaryBalanceAfter: metadata.SourceSalaryBalanceAfter,
SellerBalanceAfter: metadata.SellerBalanceAfter,
SalaryUSDMinor: metadata.SalaryUSDMinor,
CoinAmount: metadata.CoinAmount,
CoinPerUSD: metadata.CoinPerUSD,
RateMinUSDMinor: metadata.RateMinUSDMinor,
RateMaxUSDMinor: metadata.RateMaxUSDMinor,
CreatedAtMS: metadata.CreatedAtMS,
}
}
func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion 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,
"balance_version": balanceVersion,
"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 {
assetType := ledger.AssetCoin
if metadata, ok := payload.(coinSellerTransferMetadata); ok {
if strings.TrimSpace(metadata.TargetAssetType) != "" {
assetType = metadata.TargetAssetType
}
rechargeType := strings.TrimSpace(metadata.RechargeType)
if rechargeType == "" {
rechargeType = "coin_seller_transfer"
}
// WalletRechargeRecorded 是统计服务的充值入口;币商转账没有 USDT 金额,但必须带目标用户真实国家/区域,避免统计继续把 region 当 country。
payload = map[string]any{
"app_code": metadata.AppCode,
"payment_order_id": metadata.PaymentOrderID,
"provider": metadata.ProviderCode,
"payment_method_id": metadata.PaymentMethodID,
"seller_user_id": metadata.SellerUserID,
"target_user_id": metadata.TargetUserID,
"target_country_id": metadata.TargetCountryID,
"country_id": metadata.TargetCountryID,
"seller_region_id": metadata.SellerRegionID,
"target_region_id": metadata.TargetRegionID,
"region_id": metadata.TargetRegionID,
"amount": metadata.Amount,
"reason": metadata.Reason,
"seller_asset_type": metadata.SellerAssetType,
"target_asset_type": metadata.TargetAssetType,
"seller_balance_after": metadata.SellerBalanceAfter,
"target_balance_after": metadata.TargetBalanceAfter,
"recharge_sequence": metadata.RechargeSequence,
"recharge_usd_minor": metadata.RechargeUSDMinor,
"recharge_currency_code": metadata.RechargeCurrencyCode,
"recharge_policy_id": metadata.RechargePolicyID,
"recharge_policy_version": metadata.RechargePolicyVersion,
"recharge_policy_coin_amount": metadata.RechargePolicyCoinAmount,
"recharge_policy_usd_minor_amount": metadata.RechargePolicyUSDMinorAmount,
"recharge_type": rechargeType,
"created_at_ms": nowMs,
}
}
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletRechargeRecorded", targetUserID, assetType),
EventType: "WalletRechargeRecorded",
TransactionID: transactionID,
CommandID: commandID,
UserID: targetUserID,
AssetType: assetType,
AvailableDelta: availableDelta,
FrozenDelta: 0,
Payload: payload,
CreatedAtMS: nowMs,
}
}
func taskRewardCreditedEvent(transactionID string, commandID string, metadata taskRewardMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletTaskRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
EventType: "WalletTaskRewardCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: metadata.Amount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": metadata.TargetUserID,
"task_type": metadata.TaskType,
"task_id": metadata.TaskID,
"cycle_key": metadata.CycleKey,
"amount": metadata.Amount,
"reason": metadata.Reason,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metadata luckyGiftRewardMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletLuckyGiftRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
EventType: "WalletLuckyGiftRewardCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: metadata.Amount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": metadata.TargetUserID,
"draw_id": metadata.DrawID,
"room_id": metadata.RoomID,
"visible_region_id": metadata.VisibleRegionID,
"region_id": metadata.VisibleRegionID,
"gift_id": metadata.GiftID,
"pool_id": metadata.PoolID,
"amount": metadata.Amount,
"reason": metadata.Reason,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func roomTurnoverRewardCreditedEvent(transactionID string, commandID string, metadata roomTurnoverRewardMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletRoomTurnoverRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
EventType: "WalletRoomTurnoverRewardCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.TargetUserID,
AssetType: ledger.AssetCoin,
AvailableDelta: metadata.Amount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": metadata.TargetUserID,
"settlement_id": metadata.SettlementID,
"room_id": metadata.RoomID,
"period_start_ms": metadata.PeriodStartMS,
"period_end_ms": metadata.PeriodEndMS,
"coin_spent": metadata.CoinSpent,
"tier_id": metadata.TierID,
"tier_code": metadata.TierCode,
"amount": metadata.Amount,
"reason": metadata.Reason,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent {
eventType := "WalletCoinSellerCoinCompensated"
if metadata.StockType == ledger.StockTypeUSDTPurchase {
eventType = "WalletCoinSellerStockPurchased"
}
return walletOutboxEvent{
EventID: eventID(transactionID, eventType, metadata.SellerUserID, ledger.AssetCoinSellerCoin),
EventType: eventType,
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.SellerUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: metadata.CoinAmount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"seller_user_id": metadata.SellerUserID,
"seller_country_id": metadata.SellerCountryID,
"seller_region_id": metadata.SellerRegionID,
"country_id": metadata.SellerCountryID,
"region_id": metadata.SellerRegionID,
"stock_type": metadata.StockType,
"coin_amount": metadata.CoinAmount,
"paid_currency_code": metadata.PaidCurrencyCode,
"paid_amount_micro": metadata.PaidAmountMicro,
"counts_as_seller_recharge": metadata.CountsAsSellerRecharge,
"operator_user_id": metadata.OperatorUserID,
"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 hostPeriodDiamondCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "HostPeriodDiamondCredited", metadata.TargetUserID, ledger.AssetHostPeriodDiamond),
EventType: "HostPeriodDiamondCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.TargetUserID,
AssetType: ledger.AssetHostPeriodDiamond,
AvailableDelta: metadata.HostPeriodDiamondAdded,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"host_user_id": metadata.TargetUserID,
"cycle_key": metadata.HostPeriodCycleKey,
"region_id": metadata.TargetHostRegionID,
"agency_owner_user_id": metadata.TargetAgencyOwnerUserID,
"diamond_delta": metadata.HostPeriodDiamondAdded,
"diamond_after": metadata.HostPeriodDiamondAfter,
"gift_charge_asset_type": metadata.ChargeAssetType,
"gift_charge_amount": metadata.ChargeAmount,
"gift_id": metadata.GiftID,
"gift_count": metadata.GiftCount,
"sender_user_id": metadata.SenderUserID,
"sender_region_id": metadata.SenderRegionID,
"room_id": metadata.RoomID,
"gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent,
"gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID,
"created_at_ms": nowMs,
},
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 checkedAdd(left int64, right int64) (int64, error) {
if right < 0 || left > math.MaxInt64-right {
return 0, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin amount overflow")
}
return left + right, nil
}
func debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, target ledger.DebitGiftTargetCommand) ledger.DebitGiftCommand {
// 批量目标最终仍落成独立 gift_debit 交易;复用单目标 hash 可以保证账务语义变化时照样触发幂等冲突。
return ledger.DebitGiftCommand{
AppCode: command.AppCode,
CommandID: target.CommandID,
RoomID: command.RoomID,
SenderUserID: command.SenderUserID,
TargetUserID: target.TargetUserID,
GiftID: command.GiftID,
GiftCount: command.GiftCount,
PriceVersion: command.PriceVersion,
RegionID: command.RegionID,
SenderRegionID: command.SenderRegionID,
TargetIsHost: target.TargetIsHost,
TargetHostRegionID: target.TargetHostRegionID,
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
}
}
func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Receipt, error) {
aggregate := ledger.Receipt{}
billingReceiptIDs := make([]string, 0, len(targets))
transactionIDs := make([]string, 0, len(targets))
for _, target := range targets {
receipt := target.Receipt
if receipt.BillingReceiptID != "" {
billingReceiptIDs = append(billingReceiptIDs, receipt.BillingReceiptID)
}
if receipt.TransactionID != "" {
transactionIDs = append(transactionIDs, receipt.TransactionID)
}
var err error
if aggregate.CoinSpent, err = checkedAdd(aggregate.CoinSpent, receipt.CoinSpent); err != nil {
return ledger.Receipt{}, err
}
if aggregate.ChargeAmount, err = checkedAdd(aggregate.ChargeAmount, receipt.ChargeAmount); err != nil {
return ledger.Receipt{}, err
}
if aggregate.HeatValue, err = checkedAdd(aggregate.HeatValue, receipt.HeatValue); err != nil {
return ledger.Receipt{}, err
}
if aggregate.HostPeriodDiamondAdded, err = checkedAdd(aggregate.HostPeriodDiamondAdded, receipt.HostPeriodDiamondAdded); err != nil {
return ledger.Receipt{}, err
}
if aggregate.ChargeAssetType == "" {
aggregate.ChargeAssetType = receipt.ChargeAssetType
} else if receipt.ChargeAssetType != "" && aggregate.ChargeAssetType != receipt.ChargeAssetType {
aggregate.ChargeAssetType = giftWallChargeAssetMixed
}
if aggregate.GiftTypeCode == "" {
aggregate.GiftTypeCode = receipt.GiftTypeCode
}
if aggregate.CPRelationType == "" {
aggregate.CPRelationType = receipt.CPRelationType
}
if aggregate.GiftName == "" {
aggregate.GiftName = receipt.GiftName
}
if aggregate.GiftIconURL == "" {
aggregate.GiftIconURL = receipt.GiftIconURL
}
if aggregate.GiftAnimationURL == "" {
aggregate.GiftAnimationURL = receipt.GiftAnimationURL
}
if aggregate.PriceVersion == "" {
aggregate.PriceVersion = receipt.PriceVersion
}
if aggregate.HostPeriodCycleKey == "" {
aggregate.HostPeriodCycleKey = receipt.HostPeriodCycleKey
}
// sender 账后余额随目标顺序逐笔递减;聚合响应返回最后一笔后的余额,表示整批完成后的余额。
aggregate.BalanceAfter = receipt.BalanceAfter
}
aggregate.BillingReceiptID = strings.Join(billingReceiptIDs, ",")
aggregate.TransactionID = strings.Join(transactionIDs, ",")
return aggregate, nil
}
func hostPeriodCycleKeyFromMS(ms int64) string {
// 工资周期用 UTC 月份锚定,避免服务节点本地时区差异导致月底礼物落到不同周期。
return time.UnixMilli(ms).UTC().Format("2006-01")
}
func debitRequestHash(command ledger.DebitGiftCommand) string {
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d",
appcode.Normalize(command.AppCode),
command.RoomID,
command.SenderUserID,
command.TargetUserID,
command.GiftID,
command.GiftCount,
strings.TrimSpace(command.PriceVersion),
command.RegionID,
command.SenderRegionID,
command.TargetIsHost,
command.TargetHostRegionID,
command.TargetAgencyOwnerUserID,
))
}
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 cpBreakupFeeRequestHash(command ledger.CPBreakupFeeCommand) string {
// 解除扣费幂等覆盖关系、关系类型、用户和费用;同 command_id 换任何字段都必须拒绝。
return stableHash(fmt.Sprintf("cp_breakup_fee|%s|%d|%s|%s|%d",
appcode.Normalize(command.AppCode),
command.UserID,
strings.TrimSpace(command.RelationshipID),
strings.ToLower(strings.TrimSpace(command.RelationType)),
command.Amount,
))
}
func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s",
appcode.Normalize(command.AppCode),
command.TargetUserID,
command.Amount,
command.TaskType,
command.TaskID,
command.CycleKey,
strings.TrimSpace(command.Reason),
))
}
func luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string {
return stableHash(fmt.Sprintf("lucky_gift_reward|%s|%d|%d|%s|%s|%s|%s|%s",
appcode.Normalize(command.AppCode),
command.TargetUserID,
command.Amount,
strings.TrimSpace(command.DrawID),
strings.TrimSpace(command.RoomID),
strings.TrimSpace(command.GiftID),
strings.TrimSpace(command.PoolID),
strings.TrimSpace(command.Reason),
))
}
func roomTurnoverRewardRequestHash(command ledger.RoomTurnoverRewardCommand) string {
return stableHash(fmt.Sprintf("room_turnover_reward|%s|%d|%d|%s|%s|%d|%d|%d|%d|%s|%s",
appcode.Normalize(command.AppCode),
command.TargetUserID,
command.Amount,
strings.TrimSpace(command.SettlementID),
strings.TrimSpace(command.RoomID),
command.PeriodStartMS,
command.PeriodEndMS,
command.CoinSpent,
command.TierID,
strings.TrimSpace(command.TierCode),
strings.TrimSpace(command.Reason),
))
}
func gameBizTypeAndDelta(opType string, coinAmount int64) (string, int64, error) {
switch ledger.NormalizeGameOpType(opType) {
case ledger.GameOpDebit:
return bizTypeGameDebit, -coinAmount, nil
case ledger.GameOpCredit:
return bizTypeGameCredit, coinAmount, nil
case ledger.GameOpRefund:
return bizTypeGameRefund, coinAmount, nil
case ledger.GameOpReverse:
// 冲正首版按“从用户 COIN 扣回”处理;后续接入平台若语义不同,必须在 adapter 层拆成 debit/refund。
return bizTypeGameReverse, -coinAmount, nil
default:
return "", 0, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
}
}
func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string {
return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%d|%d|%s|%d|%s|%d|%s|%s|%d|%s",
appcode.Normalize(command.AppCode),
command.SellerUserID,
command.SellerCountryID,
command.SellerRegionID,
command.StockType,
command.CoinAmount,
command.PaidCurrencyCode,
command.PaidAmountMicro,
command.PaymentRef,
command.EvidenceRef,
command.OperatorUserID,
command.Reason,
))
}
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 salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string {
return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s",
appcode.Normalize(command.AppCode),
command.UserID,
strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
command.SalaryUSDMinor,
strings.TrimSpace(command.Reason),
))
}
func salaryTransferToCoinSellerRequestHash(command ledger.SalaryTransferToCoinSellerCommand) string {
return stableHash(fmt.Sprintf("salary_transfer_coin_seller|%s|%d|%d|%s|%d|%d|%s",
appcode.Normalize(command.AppCode),
command.SourceUserID,
command.SellerUserID,
strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
command.SalaryUSDMinor,
command.RegionID,
strings.TrimSpace(command.Reason),
))
}
func coinSellerStockBizType(stockType string) string {
switch stockType {
case ledger.StockTypeUSDTPurchase:
return bizTypeCoinSellerStockPurchase
case ledger.StockTypeCoinCompensation:
return bizTypeCoinSellerCoinCompensation
default:
return ""
}
}
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 coinSellerStockID(appCode string, commandID string) string {
return "cstock_" + 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))
}
func isMySQLDuplicateError(err error) bool {
var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}