mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-08 08:55:22 +00:00
Merge pull request #3404 from wucm667/fix/balance-preflight-prevent-overdraft
fix(billing): 余额扣减增加充足性校验,防止余额透支为负
This commit is contained in:
10 files changed
+335
-7
No files matched your search
@@ -113,11 +113,12 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t
|
||||
}
|
||||
|
||||
if cmd.BalanceCost > 0 {
|
||||
newBalance, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost)
|
||||
newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.NewBalance = &newBalance
|
||||
result.BalanceOverdrafted = !sufficient
|
||||
}
|
||||
|
||||
if cmd.APIKeyQuotaCost > 0 {
|
||||
@@ -173,9 +174,23 @@ func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscrip
|
||||
return service.ErrSubscriptionNotFound
|
||||
}
|
||||
|
||||
func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, error) {
|
||||
func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, bool, error) {
|
||||
var newBalance float64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET balance = balance - $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND deleted_at IS NULL AND balance >= $1
|
||||
RETURNING balance
|
||||
`, amount, userID).Scan(&newBalance)
|
||||
if err == nil {
|
||||
return newBalance, true, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET balance = balance - $1,
|
||||
updated_at = NOW()
|
||||
@@ -183,12 +198,12 @@ func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, am
|
||||
RETURNING balance
|
||||
`, amount, userID).Scan(&newBalance)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, service.ErrUserNotFound
|
||||
return 0, false, service.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, false, err
|
||||
}
|
||||
return newBalance, nil
|
||||
return newBalance, false, nil
|
||||
}
|
||||
|
||||
func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID int64, amount float64) (bool, error) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build unit
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
conditionalBalanceDeductSQL = `(?s)UPDATE users\s+SET balance = balance - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND balance >= \$1\s+RETURNING balance`
|
||||
overdraftBalanceDeductSQL = `(?s)UPDATE users\s+SET balance = balance - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL\s+RETURNING balance`
|
||||
)
|
||||
|
||||
func TestDeductUsageBillingBalance_UsesSufficientBalanceGuard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(2.5, int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(7.5))
|
||||
mock.ExpectCommit()
|
||||
|
||||
newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, 42, 2.5)
|
||||
require.NoError(t, err)
|
||||
require.True(t, sufficient)
|
||||
require.InDelta(t, 7.5, newBalance, 0.000001)
|
||||
require.NoError(t, tx.Commit())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestDeductUsageBillingBalance_RecordsOverdraftWhenGuardMisses(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(overdraftBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(-5.0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, 42, 10)
|
||||
require.NoError(t, err)
|
||||
require.False(t, sufficient)
|
||||
require.InDelta(t, -5.0, newBalance, 0.000001)
|
||||
require.NoError(t, tx.Commit())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestApplyUsageBillingEffects_FlagsBalanceOverdraft(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(overdraftBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(-5.0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result := &service.UsageBillingApplyResult{Applied: true}
|
||||
err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{
|
||||
UserID: 42,
|
||||
BalanceCost: 10,
|
||||
}, result)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result.NewBalance)
|
||||
require.InDelta(t, -5.0, *result.NewBalance, 0.000001)
|
||||
require.True(t, result.BalanceOverdrafted)
|
||||
require.NoError(t, tx.Commit())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestDeductUsageBillingBalance_ReturnsUserNotFoundWhenNoUserUpdated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(overdraftBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectRollback()
|
||||
|
||||
_, _, err = deductUsageBillingBalance(ctx, tx, 42, 10)
|
||||
require.ErrorIs(t, err, service.ErrUserNotFound)
|
||||
require.NoError(t, tx.Rollback())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
@@ -757,6 +757,17 @@ func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount flo
|
||||
func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount float64) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
n, err := client.User.Update().
|
||||
Where(dbuser.IDEQ(id), dbuser.BalanceGTE(amount)).
|
||||
AddBalance(-amount).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
n, err = client.User.Update().
|
||||
Where(dbuser.IDEQ(id)).
|
||||
AddBalance(-amount).
|
||||
Save(ctx)
|
||||
|
||||
Reference in new issue
Block a user