package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "strings" "time" _ "github.com/go-sql-driver/mysql" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) // Repository 是 wallet-service 的 MySQL 账本入口。 type Repository struct { // db 是余额、流水和幂等记录的同一事务边界。 db *sql.DB // currency 约束当前服务实例处理的账本币种,避免请求侧伪造币种。 currency string // idempotencyTTL 控制幂等记录保留窗口,过期清理可以后续由后台任务执行。 idempotencyTTL time.Duration } // Open 创建 MySQL 连接池并做启动 ping。 func Open(ctx context.Context, dsn string, currency string, idempotencyTTLSec int64) (*Repository, error) { if strings.TrimSpace(dsn) == "" { return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required") } if strings.TrimSpace(currency) == "" { return nil, xerr.New(xerr.InvalidArgument, "wallet currency is required") } if idempotencyTTLSec <= 0 { // 非正 TTL 会让幂等记录立即过期,本地和线上都拒绝这种配置。 return nil, xerr.New(xerr.InvalidArgument, "wallet idempotency ttl must be positive") } db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, err } return &Repository{ db: db, currency: currency, idempotencyTTL: time.Duration(idempotencyTTLSec) * time.Second, }, nil } // Close 释放 MySQL 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { return nil } return r.db.Close() } // Ping 验证 MySQL 当前可用。 func (r *Repository) Ping(ctx context.Context) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } return r.db.PingContext(ctx) } // DebitGift 在一个 MySQL 事务内完成幂等检查、余额锁定、扣减和流水落账。 func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) { if r == nil || r.db == nil { return ledger.Receipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.Receipt{}, err } defer func() { // Commit 成功后 Rollback 会返回 sql.ErrTxDone;忽略它可保证所有早退路径释放事务。 _ = tx.Rollback() }() requestHash := debitRequestHash(command) if receipt, exists, err := r.lookupIdempotentReceipt(ctx, tx, command, requestHash); err != nil || exists { return receipt, err } total := command.TotalGiftValue() account, err := r.lockAccount(ctx, tx, command.SenderUserID) if err != nil { return ledger.Receipt{}, err } if account.balance < total { return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } nowMs := time.Now().UnixMilli() receipt := ledger.Receipt{ BillingReceiptID: billingReceiptID(command.CommandID), TotalGiftValue: total, BalanceAfter: account.balance - total, } if err := r.saveDebit(ctx, tx, command, receipt, requestHash, nowMs); err != nil { return ledger.Receipt{}, err } if err := tx.Commit(); err != nil { return ledger.Receipt{}, err } return receipt, nil } type walletAccount struct { balance int64 version int64 } func (r *Repository) lookupIdempotentReceipt(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, requestHash string) (ledger.Receipt, bool, error) { var billingReceiptID string var storedHash string row := tx.QueryRowContext(ctx, `SELECT billing_receipt_id, request_hash FROM wallet_idempotency WHERE command_id = ? FOR UPDATE`, command.CommandID, ) if err := row.Scan(&billingReceiptID, &storedHash); err != nil { if errors.Is(err, sql.ErrNoRows) { // 没有幂等记录说明该账务命令尚未成功提交。 return ledger.Receipt{}, false, nil } return ledger.Receipt{}, false, err } if storedHash != requestHash { // 同一个 command_id 携带不同扣费内容不能回放旧回执,否则会掩盖上游幂等键复用错误。 return ledger.Receipt{}, true, xerr.New(xerr.LedgerConflict, "billing command idempotency conflict") } receipt, err := r.getReceipt(ctx, tx, command.CommandID, billingReceiptID) return receipt, true, err } func (r *Repository) getReceipt(ctx context.Context, tx *sql.Tx, commandID string, billingReceiptID string) (ledger.Receipt, error) { row := tx.QueryRowContext(ctx, `SELECT billing_receipt_id, amount, balance_after FROM wallet_ledger WHERE command_id = ? AND billing_receipt_id = ? AND currency = ? LIMIT 1`, commandID, billingReceiptID, r.currency, ) var receipt ledger.Receipt if err := row.Scan(&receipt.BillingReceiptID, &receipt.TotalGiftValue, &receipt.BalanceAfter); err != nil { if errors.Is(err, sql.ErrNoRows) { // 幂等记录存在但流水缺失代表事务外数据被破坏,不能当作可重试业务错误。 return ledger.Receipt{}, xerr.New(xerr.Internal, "wallet ledger record is missing") } return ledger.Receipt{}, err } return receipt, nil } func (r *Repository) lockAccount(ctx context.Context, tx *sql.Tx, userID int64) (walletAccount, error) { row := tx.QueryRowContext(ctx, `SELECT balance, version FROM wallet_accounts WHERE user_id = ? AND currency = ? FOR UPDATE`, userID, r.currency, ) var account walletAccount if err := row.Scan(&account.balance, &account.version); err != nil { if errors.Is(err, sql.ErrNoRows) { // 未开户在扣费语义上等价于没有可用余额,避免向房间链路暴露账务内部结构。 return walletAccount{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } return walletAccount{}, err } return account, nil } func (r *Repository) saveDebit(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, receipt ledger.Receipt, requestHash string, nowMs int64) error { if _, err := tx.ExecContext(ctx, `UPDATE wallet_accounts SET balance = ?, version = version + 1, updated_at_ms = ? WHERE user_id = ? AND currency = ?`, receipt.BalanceAfter, nowMs, command.SenderUserID, r.currency, ); err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO wallet_ledger ( billing_receipt_id, command_id, user_id, room_id, counterparty_user_id, currency, amount, direction, balance_after, biz_type, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, receipt.BillingReceiptID, command.CommandID, command.SenderUserID, command.RoomID, command.TargetUserID, r.currency, receipt.TotalGiftValue, "debit", receipt.BalanceAfter, "gift", nowMs, ); err != nil { return err } _, err := tx.ExecContext(ctx, `INSERT INTO wallet_idempotency ( command_id, billing_receipt_id, request_hash, expires_at_ms, created_at_ms ) VALUES (?, ?, ?, ?, ?)`, command.CommandID, receipt.BillingReceiptID, requestHash, nowMs+r.idempotencyTTL.Milliseconds(), nowMs, ) return err } func debitRequestHash(command ledger.DebitGiftCommand) string { // 哈希覆盖会影响扣费结果的全部字段,用于识别同一 command_id 的错误复用。 sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s|%d|%d", command.RoomID, command.SenderUserID, command.TargetUserID, command.GiftID, command.GiftCount, command.GiftUnitValue, ))) return hex.EncodeToString(sum[:]) } func billingReceiptID(commandID string) string { sum := sha256.Sum256([]byte(commandID)) // receipt_id 有 96 字符上限,使用哈希避免上游 command_id 过长导致落账失败。 return "bill_" + hex.EncodeToString(sum[:]) }