feat(billing): 完成异步结算与请求执行闭环
统一任务成功、Attempt 与结算 Outbox 的事务边界,增加多实例安全结算、人工复核、请求幂等与执行租约。钱包决策使用九位精确金额,并通过审计保护约束保留流水事实;同时补充管理接口、指标与 PostgreSQL 集成测试。
This commit is contained in:
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -82,6 +83,7 @@ type WalletBalanceAdjustmentInput struct {
|
||||
GatewayUserID string `json:"gatewayUserId"`
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
BalanceText string `json:"-"`
|
||||
Reason string `json:"reason"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@@ -91,6 +93,7 @@ type WalletRechargeInput struct {
|
||||
GatewayUserID string `json:"gatewayUserId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
AmountText string `json:"-"`
|
||||
Reason string `json:"reason"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@@ -135,7 +138,7 @@ func (s *Store) WalletAvailability(ctx context.Context, user *auth.User, currenc
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *auth.User, billings []any) ([]WalletBillingReservation, error) {
|
||||
func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *auth.User, billings []any, pricingSnapshots ...map[string]any) ([]WalletBillingReservation, error) {
|
||||
gatewayUserID := taskGatewayUserID(task, user)
|
||||
if gatewayUserID == "" {
|
||||
return nil, nil
|
||||
@@ -145,12 +148,21 @@ func (s *Store) ReserveTaskBilling(ctx context.Context, task GatewayTask, user *
|
||||
return nil, fmt.Errorf("task id is required for wallet reservation")
|
||||
}
|
||||
|
||||
pricingSnapshot := map[string]any{}
|
||||
if len(pricingSnapshots) > 0 {
|
||||
pricingSnapshot = emptyObjectIfNil(pricingSnapshots[0])
|
||||
}
|
||||
if exactAmount := walletString(pricingSnapshot["reservationAmount"]); exactAmount != "" {
|
||||
return s.reserveTaskBillingExact(ctx, task, gatewayUserID, exactAmount, pricingSnapshot)
|
||||
}
|
||||
amounts := walletBillingAmounts(billings)
|
||||
if len(amounts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
reservations := make([]WalletBillingReservation, 0, len(amounts))
|
||||
pricingSnapshotJSON, _ := json.Marshal(pricingSnapshot)
|
||||
requestFingerprint := walletString(pricingSnapshot["requestFingerprint"])
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
for currency, rawAmount := range amounts {
|
||||
amount := roundMoney(rawAmount)
|
||||
@@ -257,7 +269,30 @@ VALUES (
|
||||
}
|
||||
reservations = append(reservations, reservation)
|
||||
}
|
||||
return nil
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks task
|
||||
SET reservation_amount = COALESCE((
|
||||
SELECT SUM(reserve.amount)
|
||||
FROM gateway_wallet_transactions reserve
|
||||
WHERE reserve.reference_type = 'gateway_task'
|
||||
AND reserve.reference_id = task.id::text
|
||||
AND reserve.transaction_type = 'reserve'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = reserve.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = reserve.idempotency_key || ':release'
|
||||
)
|
||||
), 0),
|
||||
billing_status = 'pending',
|
||||
billing_currency = 'resource',
|
||||
pricing_snapshot = $2::jsonb,
|
||||
request_fingerprint = NULLIF($3, ''),
|
||||
billing_updated_at = now(),
|
||||
updated_at = now()
|
||||
WHERE task.id = $1::uuid`, taskID, string(pricingSnapshotJSON), requestFingerprint)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -265,6 +300,106 @@ VALUES (
|
||||
return reservations, err
|
||||
}
|
||||
|
||||
func (s *Store) reserveTaskBillingExact(ctx context.Context, task GatewayTask, gatewayUserID string, amount string, pricingSnapshot map[string]any) ([]WalletBillingReservation, error) {
|
||||
taskID := strings.TrimSpace(task.ID)
|
||||
currency := normalizeWalletCurrency(walletString(pricingSnapshot["currency"]))
|
||||
if currency != "resource" {
|
||||
return nil, fmt.Errorf("unsupported billing currency %q", currency)
|
||||
}
|
||||
pricingSnapshotJSON, _ := json.Marshal(pricingSnapshot)
|
||||
requestFingerprint := walletString(pricingSnapshot["requestFingerprint"])
|
||||
var reservations []WalletBillingReservation
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var positive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric(38, 9) > 0`, amount).Scan(&positive); err != nil {
|
||||
return fmt.Errorf("invalid exact reservation amount: %w", err)
|
||||
}
|
||||
if !positive {
|
||||
return nil
|
||||
}
|
||||
account, err := s.ensureWalletAccount(ctx, tx, gatewayUserID, currency)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
locked, err := lockWalletAccount(ctx, tx, account.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activeKey, activeAmount, err := activeWalletReservationExact(ctx, tx, locked.ID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activeAmount != "" {
|
||||
presentationAmount, _ := strconv.ParseFloat(activeAmount, 64)
|
||||
reservations = []WalletBillingReservation{{
|
||||
TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID,
|
||||
GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID),
|
||||
Currency: locked.Currency, Amount: presentationAmount, IdempotencyKey: activeKey,
|
||||
}}
|
||||
return nil
|
||||
}
|
||||
sequence, err := nextWalletReservationSequence(ctx, tx, locked.ID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := billingReservationIdempotencyKey(taskID, locked.Currency, sequence)
|
||||
var balanceBefore string
|
||||
var frozenBefore string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT balance::text, frozen_balance::text
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore, &frozenBefore); err != nil {
|
||||
return err
|
||||
}
|
||||
var frozenAfter string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET frozen_balance = frozen_balance + $2::numeric(38, 9), updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND balance - frozen_balance >= $2::numeric(38, 9)
|
||||
RETURNING frozen_balance::text`, locked.ID, amount).Scan(&frozenAfter)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrInsufficientWalletBalance
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"taskId": taskID, "reserved": amount, "balance": balanceBefore,
|
||||
"frozenBefore": frozenBefore, "frozenAfter": frozenAfter,
|
||||
"pricingVersion": walletString(pricingSnapshot["pricingVersion"]),
|
||||
})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_transactions (
|
||||
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
|
||||
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'debit', 'reserve',
|
||||
$4::numeric(38, 9), $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::jsonb
|
||||
)`, locked.ID, firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID), gatewayUserID,
|
||||
amount, balanceBefore, key, taskID, string(metadata)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET reservation_amount = $2::numeric(38, 9), billing_status = 'pending',
|
||||
billing_currency = $3, pricing_snapshot = $4::jsonb,
|
||||
request_fingerprint = NULLIF($5, ''), billing_updated_at = now(), updated_at = now()
|
||||
WHERE id = $1::uuid`, taskID, amount, currency, string(pricingSnapshotJSON), requestFingerprint); err != nil {
|
||||
return err
|
||||
}
|
||||
presentationAmount, _ := strconv.ParseFloat(amount, 64)
|
||||
reservations = []WalletBillingReservation{{
|
||||
TaskID: taskID, AccountID: locked.ID, GatewayUserID: gatewayUserID,
|
||||
GatewayTenantID: firstNonEmpty(locked.GatewayTenantID, task.GatewayTenantID),
|
||||
Currency: locked.Currency, Amount: presentationAmount, IdempotencyKey: key,
|
||||
}}
|
||||
return nil
|
||||
})
|
||||
return reservations, err
|
||||
}
|
||||
|
||||
func (s *Store) ReleaseTaskBillingReservations(ctx context.Context, reservations []WalletBillingReservation, reason string) error {
|
||||
if len(reservations) == 0 {
|
||||
return nil
|
||||
@@ -274,10 +409,14 @@ func (s *Store) ReleaseTaskBillingReservations(ctx context.Context, reservations
|
||||
reason = "task_not_settled"
|
||||
}
|
||||
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
taskIDs := map[string]struct{}{}
|
||||
for _, reservation := range reservations {
|
||||
if reservation.Amount <= 0 || strings.TrimSpace(reservation.AccountID) == "" {
|
||||
if strings.TrimSpace(reservation.AccountID) == "" {
|
||||
continue
|
||||
}
|
||||
if taskID := strings.TrimSpace(reservation.TaskID); taskID != "" {
|
||||
taskIDs[taskID] = struct{}{}
|
||||
}
|
||||
reserveKey := strings.TrimSpace(reservation.IdempotencyKey)
|
||||
if reserveKey == "" {
|
||||
reserveKey = billingReservationIdempotencyKey(reservation.TaskID, reservation.Currency, 1)
|
||||
@@ -303,39 +442,44 @@ SELECT EXISTS (
|
||||
if alreadyReleased {
|
||||
continue
|
||||
}
|
||||
var storedReservedAmount float64
|
||||
var storedReservedAmount string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE((
|
||||
SELECT amount::float8
|
||||
SELECT amount::text
|
||||
FROM gateway_wallet_transactions
|
||||
WHERE account_id = $1::uuid
|
||||
AND idempotency_key = $2
|
||||
AND transaction_type = 'reserve'
|
||||
LIMIT 1
|
||||
), 0)::float8`, reservation.AccountID, reserveKey).Scan(&storedReservedAmount); err != nil {
|
||||
), '0')`, reservation.AccountID, reserveKey).Scan(&storedReservedAmount); err != nil {
|
||||
return err
|
||||
}
|
||||
if storedReservedAmount <= 0 {
|
||||
var positive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric > 0`, storedReservedAmount).Scan(&positive); err != nil {
|
||||
return err
|
||||
}
|
||||
if !positive {
|
||||
continue
|
||||
}
|
||||
|
||||
amount := roundMoney(storedReservedAmount)
|
||||
frozenAfter := roundMoney(locked.FrozenBalance - amount)
|
||||
if frozenAfter < 0 {
|
||||
frozenAfter = 0
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
var balanceBefore string
|
||||
var frozenBefore string
|
||||
var frozenAfter string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET frozen_balance = $2,
|
||||
SET frozen_balance = frozen_balance - $2::numeric,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`, locked.ID, frozenAfter); err != nil {
|
||||
WHERE id = $1::uuid
|
||||
AND frozen_balance >= $2::numeric
|
||||
RETURNING balance::text, (frozen_balance + $2::numeric)::text, frozen_balance::text`,
|
||||
locked.ID, storedReservedAmount).Scan(&balanceBefore, &frozenBefore, &frozenAfter); err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"taskId": reservation.TaskID,
|
||||
"reason": reason,
|
||||
"reserved": amount,
|
||||
"frozenBefore": roundMoney(locked.FrozenBalance),
|
||||
"reserved": storedReservedAmount,
|
||||
"frozenBefore": frozenBefore,
|
||||
"frozenAfter": frozenAfter,
|
||||
})
|
||||
if _, err := tx.Exec(ctx, `
|
||||
@@ -345,15 +489,14 @@ INSERT INTO gateway_wallet_transactions (
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'release',
|
||||
$4, $5, $6, $7, 'gateway_task', $8, $9::jsonb
|
||||
$4::numeric, $5::numeric, $5::numeric, $6, 'gateway_task', $7, $8::jsonb
|
||||
)
|
||||
ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`,
|
||||
locked.ID,
|
||||
locked.GatewayTenantID,
|
||||
locked.GatewayUserID,
|
||||
amount,
|
||||
roundMoney(locked.Balance),
|
||||
roundMoney(locked.Balance),
|
||||
storedReservedAmount,
|
||||
balanceBefore,
|
||||
releaseKey,
|
||||
reservation.TaskID,
|
||||
string(metadata),
|
||||
@@ -361,6 +504,33 @@ ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO N
|
||||
return err
|
||||
}
|
||||
}
|
||||
for taskID := range taskIDs {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_tasks task
|
||||
SET reservation_amount = COALESCE((
|
||||
SELECT SUM(reserve.amount)
|
||||
FROM gateway_wallet_transactions reserve
|
||||
WHERE reserve.reference_type = 'gateway_task'
|
||||
AND reserve.reference_id = task.id::text
|
||||
AND reserve.transaction_type = 'reserve'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = reserve.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = reserve.idempotency_key || ':release'
|
||||
)
|
||||
), 0),
|
||||
billing_status = CASE
|
||||
WHEN status IN ('failed', 'cancelled') THEN 'released'
|
||||
WHEN status = 'succeeded' THEN billing_status
|
||||
ELSE 'not_started'
|
||||
END,
|
||||
billing_updated_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -588,8 +758,20 @@ func (s *Store) SetUserWalletBalanceTx(ctx context.Context, tx Tx, input WalletB
|
||||
if input.GatewayUserID == "" {
|
||||
return WalletAdjustmentResult{}, ErrLocalUserRequired
|
||||
}
|
||||
if input.Balance < 0 {
|
||||
return WalletAdjustmentResult{}, fmt.Errorf("wallet balance cannot be negative")
|
||||
targetInput := strings.TrimSpace(input.BalanceText)
|
||||
if targetInput == "" {
|
||||
targetInput = strconv.FormatFloat(input.Balance, 'f', 9, 64)
|
||||
}
|
||||
targetBalance, err := canonicalWalletAmount(ctx, tx, targetInput)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
var nonnegative bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric >= 0`, targetBalance).Scan(&nonnegative); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if !nonnegative {
|
||||
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
|
||||
}
|
||||
account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
@@ -608,38 +790,68 @@ FOR UPDATE`, account.ID))
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
before := locked
|
||||
nextBalance := roundMoney(input.Balance)
|
||||
delta := roundMoney(nextBalance - locked.Balance)
|
||||
if delta == 0 {
|
||||
var balanceBefore string
|
||||
var frozenBalance string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT balance::text, frozen_balance::text
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore, &frozenBalance); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
var unchanged bool
|
||||
var belowFrozen bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT $1::numeric = $2::numeric, $1::numeric < $3::numeric`,
|
||||
targetBalance, balanceBefore, frozenBalance).Scan(&unchanged, &belowFrozen); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if unchanged {
|
||||
return WalletAdjustmentResult{}, ErrWalletBalanceUnchanged
|
||||
}
|
||||
if belowFrozen {
|
||||
return WalletAdjustmentResult{}, ErrBalanceBelowFrozen
|
||||
}
|
||||
direction := "credit"
|
||||
amount := delta
|
||||
if delta < 0 {
|
||||
if strings.HasPrefix(balanceBefore, "-") {
|
||||
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
|
||||
}
|
||||
var targetGreater bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric > $2::numeric`, targetBalance, balanceBefore).Scan(&targetGreater); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if !targetGreater {
|
||||
direction = "debit"
|
||||
amount = -delta
|
||||
}
|
||||
var amount string
|
||||
if err := tx.QueryRow(ctx, `SELECT abs($1::numeric - $2::numeric)::text`, targetBalance, balanceBefore).Scan(&amount); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
reason = "后台余额调整"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET balance = $2,
|
||||
total_recharged = total_recharged + CASE WHEN $3 = 'credit' THEN $4 ELSE 0 END,
|
||||
SET balance = $2::numeric,
|
||||
total_recharged = total_recharged + CASE WHEN $3 = 'credit' THEN $4::numeric ELSE 0 END,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`,
|
||||
WHERE id = $1::uuid
|
||||
AND $2::numeric >= frozen_balance`,
|
||||
locked.ID,
|
||||
nextBalance,
|
||||
targetBalance,
|
||||
direction,
|
||||
amount,
|
||||
); err != nil {
|
||||
)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return WalletAdjustmentResult{}, ErrBalanceBelowFrozen
|
||||
}
|
||||
metadata := mergeObjects(input.Metadata, map[string]any{
|
||||
"reason": reason,
|
||||
"previousBalance": roundMoney(before.Balance),
|
||||
"targetBalance": nextBalance,
|
||||
"previousBalance": balanceBefore,
|
||||
"targetBalance": targetBalance,
|
||||
})
|
||||
metadataJSON, _ := json.Marshal(emptyObjectIfNil(metadata))
|
||||
transaction, err := scanWalletTransaction(tx.QueryRow(ctx, `
|
||||
@@ -649,7 +861,7 @@ INSERT INTO gateway_wallet_transactions (
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, $4, 'admin_adjust',
|
||||
$5, $6, $7, NULLIF($8, ''), 'gateway_user', $9, $10::jsonb
|
||||
$5::numeric, $6::numeric, $7::numeric, NULLIF($8, ''), 'gateway_user', $9, $10::jsonb
|
||||
)
|
||||
RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COALESCE(gateway_user_id::text, ''),
|
||||
direction, transaction_type, amount::float8, balance_before::float8, balance_after::float8,
|
||||
@@ -660,8 +872,8 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
locked.GatewayUserID,
|
||||
direction,
|
||||
amount,
|
||||
roundMoney(before.Balance),
|
||||
nextBalance,
|
||||
balanceBefore,
|
||||
targetBalance,
|
||||
strings.TrimSpace(input.IdempotencyKey),
|
||||
locked.GatewayUserID,
|
||||
string(metadataJSON),
|
||||
@@ -669,11 +881,10 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
locked.Balance = nextBalance
|
||||
if direction == "credit" {
|
||||
locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount)
|
||||
locked, err = s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
locked.UpdatedAt = time.Now()
|
||||
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
|
||||
}
|
||||
|
||||
@@ -695,9 +906,20 @@ func (s *Store) RechargeUserWalletBalanceTx(ctx context.Context, tx Tx, input Wa
|
||||
if input.GatewayUserID == "" {
|
||||
return WalletAdjustmentResult{}, ErrLocalUserRequired
|
||||
}
|
||||
amount := roundMoney(input.Amount)
|
||||
if amount <= 0 {
|
||||
return WalletAdjustmentResult{}, fmt.Errorf("wallet recharge amount must be positive")
|
||||
amountInput := strings.TrimSpace(input.AmountText)
|
||||
if amountInput == "" {
|
||||
amountInput = strconv.FormatFloat(input.Amount, 'f', 9, 64)
|
||||
}
|
||||
amount, err := canonicalWalletAmount(ctx, tx, amountInput)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
var positive bool
|
||||
if err := tx.QueryRow(ctx, `SELECT $1::numeric > 0`, amount).Scan(&positive); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
if !positive {
|
||||
return WalletAdjustmentResult{}, ErrInvalidWalletAmount
|
||||
}
|
||||
account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
@@ -715,26 +937,30 @@ FOR UPDATE`, account.ID))
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
before := locked
|
||||
nextBalance := roundMoney(locked.Balance + amount)
|
||||
var balanceBefore string
|
||||
if err := tx.QueryRow(ctx, `SELECT balance::text FROM gateway_wallet_accounts WHERE id = $1::uuid`, locked.ID).Scan(&balanceBefore); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
reason = "后台余额充值"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
var nextBalance string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE gateway_wallet_accounts
|
||||
SET balance = $2,
|
||||
total_recharged = total_recharged + $3,
|
||||
SET balance = balance + $2::numeric,
|
||||
total_recharged = total_recharged + $2::numeric,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid`,
|
||||
WHERE id = $1::uuid
|
||||
RETURNING balance::text`,
|
||||
locked.ID,
|
||||
nextBalance,
|
||||
amount,
|
||||
); err != nil {
|
||||
).Scan(&nextBalance); err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
metadata := mergeObjects(input.Metadata, map[string]any{
|
||||
"reason": reason,
|
||||
"previousBalance": roundMoney(before.Balance),
|
||||
"previousBalance": balanceBefore,
|
||||
"rechargeAmount": amount,
|
||||
"targetBalance": nextBalance,
|
||||
})
|
||||
@@ -746,7 +972,7 @@ INSERT INTO gateway_wallet_transactions (
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'recharge',
|
||||
$4, $5, $6, NULLIF($7, ''), 'gateway_user', $8, $9::jsonb
|
||||
$4::numeric, $5::numeric, $6::numeric, NULLIF($7, ''), 'gateway_user', $8, $9::jsonb
|
||||
)
|
||||
RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COALESCE(gateway_user_id::text, ''),
|
||||
direction, transaction_type, amount::float8, balance_before::float8, balance_after::float8,
|
||||
@@ -756,7 +982,7 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
locked.GatewayTenantID,
|
||||
locked.GatewayUserID,
|
||||
amount,
|
||||
roundMoney(before.Balance),
|
||||
balanceBefore,
|
||||
nextBalance,
|
||||
strings.TrimSpace(input.IdempotencyKey),
|
||||
locked.GatewayUserID,
|
||||
@@ -765,12 +991,21 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
locked.Balance = nextBalance
|
||||
locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount)
|
||||
locked.UpdatedAt = time.Now()
|
||||
locked, err = s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
|
||||
if err != nil {
|
||||
return WalletAdjustmentResult{}, err
|
||||
}
|
||||
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
|
||||
}
|
||||
|
||||
func canonicalWalletAmount(ctx context.Context, q Tx, value string) (string, error) {
|
||||
var canonical string
|
||||
if err := q.QueryRow(ctx, `SELECT $1::numeric(38, 9)::text`, strings.TrimSpace(value)).Scan(&canonical); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidWalletAmount, err)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureWalletAccount(ctx context.Context, q Tx, gatewayUserID string, currency string) (GatewayWalletAccount, error) {
|
||||
currency = normalizeWalletCurrency(currency)
|
||||
if _, err := q.Exec(ctx, `
|
||||
@@ -794,6 +1029,9 @@ WHERE gateway_wallet_accounts.gateway_tenant_id IS NULL
|
||||
OR COALESCE(gateway_wallet_accounts.user_id, '') = ''`, gatewayUserID, currency); err != nil {
|
||||
return GatewayWalletAccount{}, err
|
||||
}
|
||||
if err := ensureWalletAccountAuditGuard(ctx, q, gatewayUserID, currency); err != nil {
|
||||
return GatewayWalletAccount{}, err
|
||||
}
|
||||
account, err := scanWalletAccount(q.QueryRow(ctx, `
|
||||
SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
|
||||
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
|
||||
@@ -811,6 +1049,17 @@ WHERE gateway_user_id = $1::uuid
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func ensureWalletAccountAuditGuard(ctx context.Context, q Tx, gatewayUserID string, currency string) error {
|
||||
_, err := q.Exec(ctx, `
|
||||
INSERT INTO gateway_wallet_account_audit_guards (account_id)
|
||||
SELECT id
|
||||
FROM gateway_wallet_accounts
|
||||
WHERE gateway_user_id = $1::uuid
|
||||
AND currency = $2
|
||||
ON CONFLICT (account_id) DO NOTHING`, gatewayUserID, normalizeWalletCurrency(currency))
|
||||
return err
|
||||
}
|
||||
|
||||
func lockWalletAccount(ctx context.Context, tx pgx.Tx, accountID string) (GatewayWalletAccount, error) {
|
||||
return scanWalletAccount(tx.QueryRow(ctx, `
|
||||
SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
|
||||
@@ -851,6 +1100,31 @@ LIMIT 1`, accountID, taskID).Scan(&key, &amount)
|
||||
return key, roundMoney(amount), nil
|
||||
}
|
||||
|
||||
func activeWalletReservationExact(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (string, string, error) {
|
||||
var key string
|
||||
var amount string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(t.idempotency_key, ''), t.amount::text
|
||||
FROM gateway_wallet_transactions t
|
||||
WHERE t.account_id = $1::uuid
|
||||
AND t.reference_type = 'gateway_task'
|
||||
AND t.reference_id = $2
|
||||
AND t.transaction_type = 'reserve'
|
||||
AND COALESCE(t.idempotency_key, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM gateway_wallet_transactions release
|
||||
WHERE release.account_id = t.account_id
|
||||
AND release.transaction_type = 'release'
|
||||
AND release.idempotency_key = t.idempotency_key || ':release'
|
||||
)
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 1`, accountID, taskID).Scan(&key, &amount)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
return key, amount, err
|
||||
}
|
||||
|
||||
func nextWalletReservationSequence(ctx context.Context, tx pgx.Tx, accountID string, taskID string) (int, error) {
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
|
||||
Reference in New Issue
Block a user