184 lines
5.8 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) {
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
})
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
}
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
}