194 lines
6.3 KiB
Go
194 lines
6.3 KiB
Go
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
|
||
}
|