2026-04-29 12:43:05 +08:00

266 lines
7.6 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"
"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[:])
}