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