2026-07-22 13:55:55 +08:00

194 lines
6.3 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"
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
type walletAccount struct {
AppCode string
UserID int64
AssetType string
AvailableAmount int64
FrozenAmount int64
Version int64
UpdatedAtMS int64
}
type walletAccountLockRequest struct {
UserID int64
AssetType string
CreateIfMissing bool
}
type walletAccountState struct {
account walletAccount
}
func walletAccountLockKey(userID int64, assetType string) string {
return fmt.Sprintf("%s:%d", strings.ToUpper(strings.TrimSpace(assetType)), userID)
}
func (r *Repository) lockAccountsOrdered(ctx context.Context, tx *sql.Tx, requests []walletAccountLockRequest, nowMs int64) (map[string]*walletAccountState, error) {
ordered, err := orderedWalletAccountLockRequests(requests)
if err != nil {
return nil, err
}
states := make(map[string]*walletAccountState, len(ordered))
for _, request := range ordered {
account, err := r.lockAccount(ctx, tx, request.UserID, request.AssetType, request.CreateIfMissing, nowMs)
if err != nil {
return nil, err
}
states[walletAccountLockKey(request.UserID, request.AssetType)] = &walletAccountState{account: account}
}
return states, nil
}
// orderedWalletAccountLockRequests 是所有多账户账变共用的稳定锁序。资产类型优先、用户 ID 次之,
// 保证送礼(可能 COIN -> POINT_DIAMOND和积分兑币POINT_DIAMOND -> COIN方向相反时也不会反向拿锁。
func orderedWalletAccountLockRequests(requests []walletAccountLockRequest) ([]walletAccountLockRequest, error) {
merged := make(map[string]walletAccountLockRequest, len(requests))
for _, request := range requests {
request.AssetType = strings.ToUpper(strings.TrimSpace(request.AssetType))
if request.UserID <= 0 || strings.TrimSpace(request.AssetType) == "" {
return nil, xerr.New(xerr.InvalidArgument, "wallet account lock request is invalid")
}
if !ledger.ValidAssetType(request.AssetType) {
return nil, xerr.New(xerr.InvalidArgument, "wallet asset_type is invalid")
}
key := walletAccountLockKey(request.UserID, request.AssetType)
if existing, ok := merged[key]; ok {
existing.CreateIfMissing = existing.CreateIfMissing || request.CreateIfMissing
merged[key] = existing
continue
}
merged[key] = request
}
ordered := make([]walletAccountLockRequest, 0, len(merged))
for _, request := range merged {
ordered = append(ordered, request)
}
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].AssetType == ordered[j].AssetType {
return ordered[i].UserID < ordered[j].UserID
}
return ordered[i].AssetType < ordered[j].AssetType
})
return ordered, nil
}
func (r *Repository) applyTrackedAccountDelta(ctx context.Context, tx *sql.Tx, state *walletAccountState, availableDelta int64, frozenDelta int64, nowMs int64) (walletAccount, error) {
if state == nil {
return walletAccount{}, xerr.New(xerr.Internal, "wallet account state is missing")
}
before := state.account
if err := r.applyAccountDelta(ctx, tx, before, availableDelta, frozenDelta, nowMs); err != nil {
return walletAccount{}, err
}
after := before
after.AvailableAmount += availableDelta
after.FrozenAmount += frozenDelta
after.Version++
after.UpdatedAtMS = nowMs
state.account = after
return after, nil
}
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
}