diff --git a/controller/topup.go b/controller/topup.go index e60312c7..64771f7c 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -187,6 +187,66 @@ func getMinTopup() int64 { return int64(minTopup) } +func getTopUpQuota(amount int64) (int, error) { + quota := decimal.NewFromInt(amount) + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quota = decimal.NewFromInt(quota.Div(quotaPerUnit).IntPart()).Mul(quotaPerUnit) + } else { + quota = quota.Mul(decimal.NewFromFloat(common.QuotaPerUnit)) + } + return common.QuotaFromDecimalStrict(quota) +} + +func getMaxTopUpAmount() int64 { + if common.QuotaPerUnit <= 0 { + return 0 + } + quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + maxStoredAmount := decimal.NewFromInt(common.MaxQuota - 1). + Div(quotaPerUnit). + Floor() + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + return maxStoredAmount.Add(decimal.NewFromInt(1)). + Mul(quotaPerUnit). + Ceil(). + Sub(decimal.NewFromInt(1)). + IntPart() + } + return maxStoredAmount.IntPart() +} + +func validateCreditedQuota(quota decimal.Decimal) error { + value, err := common.QuotaFromDecimalStrict(quota) + if err != nil { + return errors.New("充值额度超出系统可表示范围") + } + if value <= 0 { + return errors.New("充值额度必须大于 0") + } + return nil +} + +func validateTopUpQuota(amount int64) error { + quota, err := getTopUpQuota(amount) + if err == nil && quota > 0 { + return nil + } + maxAmount := getMaxTopUpAmount() + if maxAmount > 0 && amount > maxAmount { + return fmt.Errorf("单笔充值数量不能大于 %d", maxAmount) + } + return errors.New("充值数量无效") +} + +func rejectInvalidTopUpQuota(c *gin.Context, amount int64) bool { + if err := validateTopUpQuota(amount); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) + return true + } + return false +} + func RequestEpay(c *gin.Context) { var req EpayRequest err := c.ShouldBindJSON(&req) @@ -198,6 +258,9 @@ func RequestEpay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) return } + if rejectInvalidTopUpQuota(c, req.Amount) { + return + } id := c.GetInt("id") group, err := model.GetUserGroup(id, true) @@ -412,6 +475,9 @@ func RequestAmount(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) return } + if rejectInvalidTopUpQuota(c, req.Amount) { + return + } id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 7472690e..442d3d8b 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -18,6 +18,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/thanhpk/randstr" ) @@ -96,6 +97,10 @@ 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()}) + return + } id := c.GetInt("id") user, _ := model.GetUserById(id, false) diff --git a/controller/topup_quota_limit_test.go b/controller/topup_quota_limit_test.go new file mode 100644 index 00000000..7afd7694 --- /dev/null +++ b/controller/topup_quota_limit_test.go @@ -0,0 +1,143 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTopUpQuotaValidation(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + common.QuotaPerUnit = 500000 + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + }) + + testCases := []struct { + name string + displayType string + amount int64 + wantQuota int + wantErr bool + }{ + { + name: "currency amount below limit", + displayType: operation_setting.QuotaDisplayTypeUSD, + amount: 4294, + wantQuota: 2_147_000_000, + }, + { + name: "currency amount above limit", + displayType: operation_setting.QuotaDisplayTypeUSD, + amount: 4295, + wantErr: true, + }, + { + name: "token amount preserves settlement truncation", + displayType: operation_setting.QuotaDisplayTypeTokens, + amount: common.MaxQuota, + wantQuota: 2_147_000_000, + }, + { + name: "token amount above settlement limit", + displayType: operation_setting.QuotaDisplayTypeTokens, + amount: 2_147_500_000, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + operation_setting.GetGeneralSetting().QuotaDisplayType = tc.displayType + quota, err := getTopUpQuota(tc.amount) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantQuota, quota) + }) + } +} + +func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + common.QuotaPerUnit = 500000 + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + }) + + maxAmount := decimal.NewFromInt(common.MaxQuota - 1). + Div(decimal.NewFromFloat(common.QuotaPerUnit)). + Floor().IntPart() + + require.NoError(t, validateTopUpQuota(maxAmount)) + err := validateTopUpQuota(maxAmount + 1) + require.EqualError(t, err, "单笔充值数量不能大于 4294") +} + +func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType + common.QuotaPerUnit = 500000 + operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType + }) + + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPost, + "/api/user/amount", + strings.NewReader(`{"amount":4295}`), + ) + ctx.Request.Header.Set("Content-Type", "application/json") + + RequestAmount(ctx) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.JSONEq(t, `{"message":"error","data":"单笔充值数量不能大于 4294"}`, recorder.Body.String()) +} + +func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) { + require.NoError(t, validateCreditedQuota(decimal.NewFromInt(common.MaxQuota-1))) + require.EqualError(t, validateCreditedQuota(decimal.Zero), "充值额度必须大于 0") + require.EqualError( + t, + validateCreditedQuota(decimal.NewFromInt(common.MaxQuota)), + "充值额度超出系统可表示范围", + ) +} + +func TestStripeCreditedQuotaIncludesGroupRatio(t *testing.T) { + oldQuotaPerUnit := common.QuotaPerUnit + oldTopupGroupRatio := common.TopupGroupRatio2JSONString() + common.QuotaPerUnit = 500000 + require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"vip":2}`)) + t.Cleanup(func() { + common.QuotaPerUnit = oldQuotaPerUnit + require.NoError(t, common.UpdateTopupGroupRatioByJSONString(oldTopupGroupRatio)) + }) + + require.NoError(t, validateCreditedQuota(getStripeCreditedQuota(2147, "vip"))) + require.Error(t, validateCreditedQuota(getStripeCreditedQuota(2148, "vip"))) + + 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 8a395766..9ee08422 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -17,6 +17,7 @@ import ( "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/stripe/stripe-go/v81" "github.com/stripe/stripe-go/v81/checkout/session" "github.com/stripe/stripe-go/v81/webhook" @@ -47,12 +48,20 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())}) return } + if req.Amount > 10000 { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值数量不能大于 10000"}) + return + } id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { 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()}) + return + } payMoney := getStripePayMoney(float64(req.Amount), group) if payMoney <= 0.01 { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) @@ -88,6 +97,12 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { id := c.GetInt("id") user, _ := model.GetUserById(id, false) chargedMoney := GetChargedAmount(float64(req.Amount), *user) + if err := validateCreditedQuota( + decimal.NewFromFloat(chargedMoney).Mul(decimal.NewFromFloat(common.QuotaPerUnit)), + ); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()}) + return + } reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) referenceId := "ref_" + common.Sha1([]byte(reference)) @@ -394,6 +409,16 @@ func GetChargedAmount(count float64, user model.User) float64 { return count * topUpGroupRatio } +func getStripeCreditedQuota(amount int64, group string) decimal.Decimal { + topUpGroupRatio := common.GetTopupGroupRatio(group) + if topUpGroupRatio == 0 { + topUpGroupRatio = 1 + } + return decimal.NewFromInt(amount). + Mul(decimal.NewFromFloat(topUpGroupRatio)). + Mul(decimal.NewFromFloat(common.QuotaPerUnit)) +} + func getStripePayMoney(amount float64, group string) float64 { originalAmount := amount if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index 4ac3b2b5..fd2100a6 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -123,6 +123,9 @@ func RequestWaffoAmount(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)}) return } + if rejectInvalidTopUpQuota(c, req.Amount) { + return + } id := c.GetInt("id") group, err := model.GetUserGroup(id, true) @@ -157,6 +160,9 @@ func RequestWaffoPay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)}) return } + if rejectInvalidTopUpQuota(c, req.Amount) { + return + } id := c.GetInt("id") user, err := model.GetUserById(id, false) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index beb73ebe..bc2c0626 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -33,6 +33,9 @@ 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) { + return + } id := c.GetInt("id") group, err := model.GetUserGroup(id, true) @@ -351,6 +354,9 @@ 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) { + return + } id := c.GetInt("id") user, err := model.GetUserById(id, false)