refactor: deprecate int32 (#7025)
* refactor: deprecate int32 * fix(db): reject legacy user quota schemas at startup * fix(quota): enforce wallet bounds and saturating billing conversions * fix(rate-limit): keep count*duration from wrapping int64 * fix: error message
This commit is contained in:
@@ -7,7 +7,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -540,15 +539,16 @@ func settleTestQuota(info *relaycommon.RelayInfo, priceData hosttypes.PriceData,
|
||||
|
||||
quota := 0
|
||||
if !priceData.UsePrice {
|
||||
quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio))
|
||||
quota = int(math.Round(float64(quota) * priceData.ModelRatio))
|
||||
completionQuota := common.QuotaRound(float64(usage.CompletionTokens) * priceData.CompletionRatio)
|
||||
quota = common.QuotaRound(float64(usage.PromptTokens) + float64(completionQuota))
|
||||
quota = common.QuotaRound(float64(quota) * priceData.ModelRatio)
|
||||
if priceData.ModelRatio != 0 && quota <= 0 {
|
||||
quota = 1
|
||||
}
|
||||
return quota, nil
|
||||
}
|
||||
|
||||
return int(priceData.ModelPrice * common.QuotaPerUnit), nil
|
||||
return common.QuotaFromFloat(priceData.ModelPrice * common.QuotaPerUnit), nil
|
||||
}
|
||||
|
||||
func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData hosttypes.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
@@ -85,6 +86,14 @@ func AddRedemption(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
||||
return
|
||||
}
|
||||
if redemption.Quota <= 0 {
|
||||
common.ApiError(c, errors.New("redemption quota must be positive"))
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
@@ -153,6 +162,14 @@ func UpdateRedemption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if statusOnly == "" {
|
||||
if redemption.Quota <= 0 {
|
||||
common.ApiError(c, errors.New("redemption quota must be positive"))
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
|
||||
+13
-2
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type tokenAutoGroupsInput struct {
|
||||
@@ -41,6 +42,16 @@ type tokenResponse struct {
|
||||
AutoGroups []string `json:"auto_groups"`
|
||||
}
|
||||
|
||||
func maxTokenQuota() int {
|
||||
quota, err := common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromInt(1_000_000_000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil {
|
||||
return common.MaxWalletQuota
|
||||
}
|
||||
return quota
|
||||
}
|
||||
|
||||
func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
|
||||
if token == nil {
|
||||
return nil
|
||||
@@ -279,7 +290,7 @@ func AddToken(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
return
|
||||
}
|
||||
maxQuotaValue := common.QuotaFromFloat(1000000000 * common.QuotaPerUnit)
|
||||
maxQuotaValue := maxTokenQuota()
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
return
|
||||
@@ -373,7 +384,7 @@ func UpdateToken(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
return
|
||||
}
|
||||
maxQuotaValue := common.QuotaFromFloat(1000000000 * common.QuotaPerUnit)
|
||||
maxQuotaValue := maxTokenQuota()
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
return
|
||||
|
||||
+8
-4
@@ -182,7 +182,11 @@ func getMinTopup() int64 {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
minTopup = common.QuotaFromDecimal(dMinTopup.Mul(dQuotaPerUnit))
|
||||
quota, err := common.WalletQuotaFromDecimalStrict(dMinTopup.Mul(dQuotaPerUnit))
|
||||
if err != nil {
|
||||
return common.MaxWalletQuota
|
||||
}
|
||||
minTopup = quota
|
||||
}
|
||||
return int64(minTopup)
|
||||
}
|
||||
@@ -195,7 +199,7 @@ func getTopUpQuota(amount int64) (int, error) {
|
||||
} else {
|
||||
quota = quota.Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
}
|
||||
return common.QuotaFromDecimalStrict(quota)
|
||||
return common.WalletQuotaFromDecimalStrict(quota)
|
||||
}
|
||||
|
||||
func getMaxTopUpAmount() int64 {
|
||||
@@ -203,7 +207,7 @@ func getMaxTopUpAmount() int64 {
|
||||
return 0
|
||||
}
|
||||
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
maxStoredAmount := decimal.NewFromInt(common.MaxQuota - 1).
|
||||
maxStoredAmount := decimal.NewFromInt(common.MaxWalletQuota).
|
||||
Div(quotaPerUnit).
|
||||
Floor()
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
@@ -217,7 +221,7 @@ func getMaxTopUpAmount() int64 {
|
||||
}
|
||||
|
||||
func validateCreditedQuota(quota decimal.Decimal) (int, error) {
|
||||
value, err := common.QuotaFromDecimalStrict(quota)
|
||||
value, err := common.WalletQuotaFromDecimalStrict(quota)
|
||||
if err != nil {
|
||||
return 0, errors.New("充值额度超出系统可表示范围")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -43,19 +44,19 @@ func TestTopUpQuotaValidation(t *testing.T) {
|
||||
name: "currency amount above limit",
|
||||
displayType: operation_setting.QuotaDisplayTypeUSD,
|
||||
amount: 4295,
|
||||
wantErr: true,
|
||||
wantQuota: 2_147_500_000,
|
||||
},
|
||||
{
|
||||
name: "token amount preserves settlement truncation",
|
||||
displayType: operation_setting.QuotaDisplayTypeTokens,
|
||||
amount: common.MaxQuota,
|
||||
wantQuota: 2_147_000_000,
|
||||
amount: 2_147_500_000,
|
||||
wantQuota: 2_147_500_000,
|
||||
},
|
||||
{
|
||||
name: "token amount above settlement limit",
|
||||
name: "token amount above legacy int32 range",
|
||||
displayType: operation_setting.QuotaDisplayTypeTokens,
|
||||
amount: 2_147_500_000,
|
||||
wantErr: true,
|
||||
amount: 4_294_500_000,
|
||||
wantQuota: 4_294_500_000,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,14 +84,14 @@ func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) {
|
||||
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
|
||||
})
|
||||
|
||||
maxAmount := decimal.NewFromInt(common.MaxQuota - 1).
|
||||
maxAmount := decimal.NewFromInt(common.MaxWalletQuota).
|
||||
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Floor().IntPart()
|
||||
|
||||
_, err := validateTopUpQuota(maxAmount)
|
||||
require.NoError(t, err)
|
||||
_, err = validateTopUpQuota(maxAmount + 1)
|
||||
require.EqualError(t, err, "单笔充值数量不能大于 4294")
|
||||
require.EqualError(t, err, fmt.Sprintf("单笔充值数量不能大于 %d", maxAmount))
|
||||
}
|
||||
|
||||
func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
||||
@@ -106,17 +107,20 @@ func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
maxAmount := decimal.NewFromInt(common.MaxWalletQuota).
|
||||
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Floor().IntPart()
|
||||
ctx.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/user/amount",
|
||||
strings.NewReader(`{"amount":4295}`),
|
||||
strings.NewReader(fmt.Sprintf(`{"amount":%d}`, maxAmount+1)),
|
||||
)
|
||||
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())
|
||||
assert.JSONEq(t, fmt.Sprintf(`{"message":"error","data":"单笔充值数量不能大于 %d"}`, maxAmount), recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
@@ -143,7 +147,7 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
require.NoError(t, model.DB.Create(&model.User{
|
||||
Id: 42,
|
||||
Username: "topup_capacity_user",
|
||||
Quota: 1_000_000,
|
||||
Quota: common.MaxWalletQuota - 100_000,
|
||||
Status: common.UserStatusEnabled,
|
||||
}).Error)
|
||||
|
||||
@@ -154,7 +158,7 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
ctx.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/user/amount",
|
||||
strings.NewReader(`{"amount":4294}`),
|
||||
strings.NewReader(`{"amount":1}`),
|
||||
)
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -165,11 +169,11 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
|
||||
_, err := validateCreditedQuota(decimal.NewFromInt(common.MaxQuota - 1))
|
||||
_, err := validateCreditedQuota(decimal.NewFromInt(int64(common.MaxWalletQuota / 2)))
|
||||
require.NoError(t, err)
|
||||
_, err = validateCreditedQuota(decimal.Zero)
|
||||
require.EqualError(t, err, "充值额度必须大于 0")
|
||||
_, err = validateCreditedQuota(decimal.NewFromInt(common.MaxQuota))
|
||||
_, err = validateCreditedQuota(decimal.NewFromInt(common.MaxWalletQuota + 1))
|
||||
require.EqualError(
|
||||
t,
|
||||
err,
|
||||
@@ -190,6 +194,8 @@ func TestStripeCreditedQuotaIncludesGroupRatio(t *testing.T) {
|
||||
_, err := validateCreditedQuota(getStripeCreditedQuota(2147, "vip"))
|
||||
require.NoError(t, err)
|
||||
_, err = validateCreditedQuota(getStripeCreditedQuota(2148, "vip"))
|
||||
require.NoError(t, err)
|
||||
_, err = validateCreditedQuota(getStripeCreditedQuota(int64(common.MaxWalletQuota), "vip"))
|
||||
require.Error(t, err)
|
||||
|
||||
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"free":0}`))
|
||||
|
||||
@@ -1167,6 +1167,10 @@ func ManageUser(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(req.Value); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -1187,6 +1191,10 @@ func ManageUser(c *gin.Context) {
|
||||
"quota": logger.LogQuota(req.Value),
|
||||
})
|
||||
case "override":
|
||||
if err := common.ValidateWalletQuota(req.Value); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
oldQuota := user.Quota
|
||||
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
|
||||
@@ -159,3 +159,24 @@ func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
|
||||
assert.EqualValues(t, 1, unchanged.AuthVersion)
|
||||
assert.Equal(t, common.UserStatusEnabled, unchanged.Status)
|
||||
}
|
||||
|
||||
func TestManageUserQuotaRespectsWalletCeiling(t *testing.T) {
|
||||
db := setupManageUserTestDB(t)
|
||||
user := model.User{
|
||||
Username: "managed-quota-user", Password: "password", Role: common.RoleCommonUser,
|
||||
Status: common.UserStatusEnabled, Group: "default", Quota: common.MaxWalletQuota - 1,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"add","value":2}`, user.Id))
|
||||
assert.Contains(t, recorder.Body.String(), `"success":false`)
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.First(&updated, user.Id).Error)
|
||||
assert.Equal(t, common.MaxWalletQuota-1, updated.Quota)
|
||||
|
||||
recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"override","value":%d}`, user.Id, common.MaxWalletQuota+1))
|
||||
assert.Contains(t, recorder.Body.String(), `"success":false`)
|
||||
require.NoError(t, db.First(&updated, user.Id).Error)
|
||||
assert.Equal(t, common.MaxWalletQuota-1, updated.Quota)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user