diff --git a/controller/topup.go b/controller/topup.go index 64771f7c..08aab813 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -216,31 +216,47 @@ func getMaxTopUpAmount() int64 { return maxStoredAmount.IntPart() } -func validateCreditedQuota(quota decimal.Decimal) error { +func validateCreditedQuota(quota decimal.Decimal) (int, error) { value, err := common.QuotaFromDecimalStrict(quota) if err != nil { - return errors.New("充值额度超出系统可表示范围") + return 0, errors.New("充值额度超出系统可表示范围") } if value <= 0 { - return errors.New("充值额度必须大于 0") + return 0, errors.New("充值额度必须大于 0") } - return nil + return value, nil } -func validateTopUpQuota(amount int64) error { +func validateTopUpQuota(amount int64) (int, error) { quota, err := getTopUpQuota(amount) if err == nil && quota > 0 { - return nil + return quota, nil } maxAmount := getMaxTopUpAmount() if maxAmount > 0 && amount > maxAmount { - return fmt.Errorf("单笔充值数量不能大于 %d", maxAmount) + return 0, fmt.Errorf("单笔充值数量不能大于 %d", maxAmount) } - return errors.New("充值数量无效") + return 0, errors.New("充值数量无效") } -func rejectInvalidTopUpQuota(c *gin.Context, amount int64) bool { - if err := validateTopUpQuota(amount); err != nil { +func rejectInvalidCreditedQuota(c *gin.Context, userId int, quota decimal.Decimal) bool { + creditedQuota, err := validateCreditedQuota(quota) + if err == nil { + err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota) + } + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) + return true + } + return false +} + +func rejectInvalidTopUpQuota(c *gin.Context, userId int, amount int64) bool { + creditedQuota, err := validateTopUpQuota(amount) + if err == nil { + err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota) + } + if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) return true } @@ -258,11 +274,11 @@ func RequestEpay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) return } - if rejectInvalidTopUpQuota(c, req.Amount) { + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { return } - id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) @@ -475,10 +491,10 @@ func RequestAmount(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) return } - if rejectInvalidTopUpQuota(c, req.Amount) { + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { return } - id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 442d3d8b..52696acc 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -97,13 +97,17 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"}) return } - if err := validateCreditedQuota(decimal.NewFromInt(selectedProduct.Quota)); err != nil { - c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) + + id := c.GetInt("id") + if rejectInvalidCreditedQuota(c, id, decimal.NewFromInt(selectedProduct.Quota)) { return } - id := c.GetInt("id") - user, _ := model.GetUserById(id, false) + user, err := model.GetUserById(id, false) + if err != nil || user == nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) + return + } // 生成唯一的订单引用ID reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) diff --git a/controller/topup_quota_limit_test.go b/controller/topup_quota_limit_test.go index 7afd7694..5c291dea 100644 --- a/controller/topup_quota_limit_test.go +++ b/controller/topup_quota_limit_test.go @@ -7,11 +7,14 @@ import ( "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestTopUpQuotaValidation(t *testing.T) { @@ -84,8 +87,9 @@ func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) { Div(decimal.NewFromFloat(common.QuotaPerUnit)). Floor().IntPart() - require.NoError(t, validateTopUpQuota(maxAmount)) - err := validateTopUpQuota(maxAmount + 1) + _, err := validateTopUpQuota(maxAmount) + require.NoError(t, err) + _, err = validateTopUpQuota(maxAmount + 1) require.EqualError(t, err, "单笔充值数量不能大于 4294") } @@ -115,12 +119,60 @@ func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) { assert.JSONEq(t, `{"message":"error","data":"单笔充值数量不能大于 4294"}`, recorder.Body.String()) } +func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + oldDB := model.DB + common.QuotaPerUnit = 500000 + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{})) + model.DB = db + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + model.DB = oldDB + sqlDB, dbErr := db.DB() + if dbErr == nil { + require.NoError(t, sqlDB.Close()) + } + }) + + require.NoError(t, model.DB.Create(&model.User{ + Id: 42, + Username: "topup_capacity_user", + Quota: 1_000_000, + Status: common.UserStatusEnabled, + }).Error) + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set("id", 42) + ctx.Request = httptest.NewRequest( + http.MethodPost, + "/api/user/amount", + strings.NewReader(`{"amount":4294}`), + ) + ctx.Request.Header.Set("Content-Type", "application/json") + + RequestAmount(ctx) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.JSONEq(t, `{"message":"error","data":"top-up quota limit exceeded"}`, recorder.Body.String()) +} + func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) { - require.NoError(t, validateCreditedQuota(decimal.NewFromInt(common.MaxQuota-1))) - require.EqualError(t, validateCreditedQuota(decimal.Zero), "充值额度必须大于 0") + _, err := validateCreditedQuota(decimal.NewFromInt(common.MaxQuota - 1)) + require.NoError(t, err) + _, err = validateCreditedQuota(decimal.Zero) + require.EqualError(t, err, "充值额度必须大于 0") + _, err = validateCreditedQuota(decimal.NewFromInt(common.MaxQuota)) require.EqualError( t, - validateCreditedQuota(decimal.NewFromInt(common.MaxQuota)), + err, "充值额度超出系统可表示范围", ) } @@ -135,8 +187,10 @@ func TestStripeCreditedQuotaIncludesGroupRatio(t *testing.T) { require.NoError(t, common.UpdateTopupGroupRatioByJSONString(oldTopupGroupRatio)) }) - require.NoError(t, validateCreditedQuota(getStripeCreditedQuota(2147, "vip"))) - require.Error(t, validateCreditedQuota(getStripeCreditedQuota(2148, "vip"))) + _, err := validateCreditedQuota(getStripeCreditedQuota(2147, "vip")) + require.NoError(t, err) + _, err = validateCreditedQuota(getStripeCreditedQuota(2148, "vip")) + require.Error(t, err) require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"free":0}`)) assert.True(t, decimal.NewFromInt(500000).Equal(getStripeCreditedQuota(1, "free"))) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 9ee08422..268b4046 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -58,8 +58,7 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) return } - if err := validateCreditedQuota(getStripeCreditedQuota(req.Amount, group)); err != nil { - c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) + if rejectInvalidCreditedQuota(c, id, getStripeCreditedQuota(req.Amount, group)) { return } payMoney := getStripePayMoney(float64(req.Amount), group) @@ -95,12 +94,15 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { } id := c.GetInt("id") - user, _ := model.GetUserById(id, false) + user, err := model.GetUserById(id, false) + if err != nil || user == nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) + return + } chargedMoney := GetChargedAmount(float64(req.Amount), *user) - if err := validateCreditedQuota( + if rejectInvalidCreditedQuota(c, id, decimal.NewFromFloat(chargedMoney).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), - ); err != nil { - c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) + ) { return } diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index fd2100a6..d803c881 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -123,11 +123,11 @@ func RequestWaffoAmount(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)}) return } - if rejectInvalidTopUpQuota(c, req.Amount) { + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { return } - id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) @@ -160,11 +160,11 @@ func RequestWaffoPay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)}) return } - if rejectInvalidTopUpQuota(c, req.Amount) { + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { return } - id := c.GetInt("id") user, err := model.GetUserById(id, false) if err != nil || user == nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index bc2c0626..b7366ec8 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -33,11 +33,11 @@ func RequestWaffoPancakeAmount(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)}) return } - if rejectInvalidTopUpQuota(c, req.Amount) { + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { return } - id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) @@ -354,11 +354,11 @@ func RequestWaffoPancakePay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)}) return } - if rejectInvalidTopUpQuota(c, req.Amount) { + id := c.GetInt("id") + if rejectInvalidTopUpQuota(c, id, req.Amount) { return } - id := c.GetInt("id") user, err := model.GetUserById(id, false) if err != nil || user == nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go index b400ad50..33da6cfa 100644 --- a/model/payment_method_guard_test.go +++ b/model/payment_method_guard_test.go @@ -308,3 +308,48 @@ func TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder(t *testing.T) { assert.Equal(t, 3, getUserQuotaForPaymentGuardTest(t, user.Id)) assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, order.TradeNo)) } + +func TestRechargeEpayEnforcesFinalWalletQuotaLimit(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + common.QuotaPerUnit = 500000 + t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit }) + + testCases := []struct { + name string + currentQuota int + wantErr bool + wantQuota int + wantStatus string + }{ + { + name: "allows exact highest representable wallet balance", + currentQuota: common.MaxQuota - 1 - 1_000_000, + wantQuota: common.MaxQuota - 1, + wantStatus: common.TopUpStatusSuccess, + }, + { + name: "rejects balance above int32 quota domain", + currentQuota: common.MaxQuota - 1_000_000, + wantErr: true, + wantQuota: common.MaxQuota - 1_000_000, + wantStatus: common.TopUpStatusPending, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + truncateTables(t) + user := insertUserForPaymentGuardTest(t, 506, tc.currentQuota) + order := createEpayTestOrder(t, user.Id, "EPAYTESTWALLETLIMIT", PaymentProviderEpay, common.TopUpStatusPending) + + _, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1") + if tc.wantErr { + require.ErrorIs(t, err, ErrTopUpQuotaLimitExceeded) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantQuota, getUserQuotaForPaymentGuardTest(t, user.Id)) + assert.Equal(t, tc.wantStatus, getTopUpStatusForPaymentGuardTest(t, order.TradeNo)) + }) + } +} diff --git a/model/topup.go b/model/topup.go index 821c77ce..d837ced2 100644 --- a/model/topup.go +++ b/model/topup.go @@ -42,9 +42,11 @@ const ( ) var ( - ErrPaymentMethodMismatch = errors.New("payment method mismatch") - ErrTopUpNotFound = errors.New("topup not found") - ErrTopUpStatusInvalid = errors.New("topup status invalid") + ErrPaymentMethodMismatch = errors.New("payment method mismatch") + ErrTopUpNotFound = errors.New("topup not found") + ErrTopUpStatusInvalid = errors.New("topup status invalid") + ErrInvalidTopUpQuota = errors.New("invalid top-up quota") + ErrTopUpQuotaLimitExceeded = errors.New("top-up quota limit exceeded") ) func (topUp *TopUp) Insert() error { @@ -53,6 +55,67 @@ func (topUp *TopUp) Insert() error { return err } +func topUpQuotaMaxCurrent(creditedQuota int) (int, error) { + if creditedQuota <= 0 || creditedQuota >= common.MaxQuota { + return 0, ErrInvalidTopUpQuota + } + return common.MaxQuota - 1 - creditedQuota, nil +} + +// ValidateTopUpQuotaCapacity performs the user-facing pre-payment check. The +// settlement path repeats the same invariant with an atomic conditional +// update, because the wallet balance can change after checkout creation. +func ValidateTopUpQuotaCapacity(userId int, creditedQuota int) error { + maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota) + if err != nil { + return err + } + + var user User + if err := DB.Select("quota").Where("id = ?", userId).First(&user).Error; err != nil { + return err + } + if user.Quota > maxCurrentQuota { + return ErrTopUpQuotaLimitExceeded + } + return nil +} + +// creditTopUpQuota atomically enforces the int32 wallet ceiling while adding +// quota. Keeping the predicate and increment in one UPDATE prevents two +// concurrent callbacks from both passing a separate read/check. +func creditTopUpQuota(tx *gorm.DB, userId int, creditedQuota int, updates map[string]interface{}) error { + maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota) + if err != nil { + return err + } + + updateFields := make(map[string]interface{}, len(updates)+1) + for key, value := range updates { + updateFields[key] = value + } + updateFields["quota"] = gorm.Expr("quota + ?", creditedQuota) + + result := tx.Model(&User{}). + Where("id = ? AND quota <= ?", userId, maxCurrentQuota). + Updates(updateFields) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 1 { + return nil + } + + var count int64 + if err := tx.Model(&User{}).Where("id = ?", userId).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return gorm.ErrRecordNotFound + } + return ErrTopUpQuotaLimitExceeded +} + func (topUp *TopUp) Update() error { var err error err = DB.Save(topUp).Error @@ -144,21 +207,14 @@ func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) ( decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), ) if quotaErr != nil || quotaToAdd <= 0 { - return errors.New("无效的充值额度") + return ErrInvalidTopUpQuota } topUp.CompleteTime = common.GetTimestamp() topUp.Status = common.TopUpStatusSuccess if err := tx.Save(topUp).Error; err != nil { return err } - result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return nil + return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil) }) if err != nil { if !errors.Is(err, ErrTopUpNotFound) && !errors.Is(err, ErrPaymentMethodMismatch) && !errors.Is(err, ErrTopUpStatusInvalid) { @@ -214,10 +270,11 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), ) if err != nil || quota <= 0 { - return errors.New("无效的充值额度") + return ErrInvalidTopUpQuota } - return tx.Model(&User{}).Where("id = ?", topUp.UserId). - Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error + return creditTopUpQuota(tx, topUp.UserId, quota, map[string]interface{}{ + "stripe_customer": customerId, + }) }) if err != nil { @@ -434,7 +491,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { ) } if quotaErr != nil || quotaToAdd <= 0 { - return errors.New("无效的充值额度") + return ErrInvalidTopUpQuota } // 标记完成 @@ -445,7 +502,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { } // 增加用户额度(立即写库,保持一致性) - if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + if err := creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil); err != nil { return err } @@ -501,13 +558,11 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string // Creem 直接使用 Amount 作为充值额度(整数) quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount)) if err != nil || quota <= 0 { - return errors.New("无效的充值额度") + return ErrInvalidTopUpQuota } // 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名 - updateFields := map[string]interface{}{ - "quota": gorm.Expr("quota + ?", quota), - } + updateFields := map[string]interface{}{} // 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时) if customerEmail != "" { @@ -524,7 +579,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string } } - return tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error + return creditTopUpQuota(tx, topUp.UserId, quota, updateFields) }) if err != nil { @@ -573,7 +628,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), ) if err != nil || quotaToAdd <= 0 { - return errors.New("无效的充值额度") + return ErrInvalidTopUpQuota } topUp.CompleteTime = common.GetTimestamp() @@ -582,7 +637,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { return err } - return tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error + return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil) }) if err != nil { @@ -633,7 +688,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), ) if err != nil || quotaToAdd <= 0 { - return errors.New("无效的充值额度") + return ErrInvalidTopUpQuota } topUp.CompleteTime = common.GetTimestamp() @@ -642,7 +697,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { return err } - return tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error + return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil) }) if err != nil {