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:
Seefs
2026-08-26 20:57:54 +08:00
committed by GitHub
parent 2d8e50bf36
commit a073f74b38
35 changed files with 445 additions and 116 deletions
+4 -4
View File
@@ -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{} {
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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("充值额度超出系统可表示范围")
}
+20 -14
View File
@@ -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}`))
+8
View File
@@ -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)
+21
View File
@@ -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)
}