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
+49
View File
@@ -186,6 +186,9 @@ func InitDB() (err error) {
panic(err)
}
}
if err := ensureUserQuotaColumns(DB, common.MainDatabaseType()); err != nil {
return err
}
sqlDB, err := DB.DB()
if err != nil {
return err
@@ -250,6 +253,52 @@ func InitLogDB() (err error) {
return err
}
var userQuotaColumns = []string{"quota", "used_quota", "aff_quota", "aff_history"}
// ensureUserQuotaColumns rejects a legacy 32-bit wallet schema before any
// migrations run. The 64-bit-only build intentionally does not auto-upgrade
// an existing wallet; operators must migrate it explicitly before starting.
func ensureUserQuotaColumns(db *gorm.DB, dbType common.DatabaseType) error {
if common.GetEnvOrDefaultBool("SKIP_64BIT_QUOTA_SCHEMA_CHECK", false) {
common.SysLog("SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check")
return nil
}
if db == nil || dbType == common.DatabaseTypeSQLite {
return nil
}
if !db.Migrator().HasTable(&User{}) {
return nil
}
columnTypes, err := db.Migrator().ColumnTypes(&User{})
if err != nil {
return fmt.Errorf("failed to inspect users schema: %w", err)
}
for _, expected := range userQuotaColumns {
for _, actual := range columnTypes {
if !strings.EqualFold(actual.Name(), expected) {
continue
}
dataType := actual.DatabaseTypeName()
if !is64BitIntegerType(dbType, dataType) {
return fmt.Errorf("users.%s uses %s; 32-bit is not supported", expected, dataType)
}
}
}
return nil
}
func is64BitIntegerType(dbType common.DatabaseType, dataType string) bool {
normalized := strings.ToLower(strings.TrimSpace(dataType))
switch dbType {
case common.DatabaseTypeMySQL:
return normalized == "bigint" || normalized == "unsigned bigint" || normalized == "bigint unsigned"
case common.DatabaseTypePostgreSQL:
return normalized == "bigint" || normalized == "int8"
default:
return false
}
}
func migrateDB() error {
// Migrate price_amount column from float/double to decimal for existing tables
migrateSubscriptionPlanPriceAmount()
+6 -6
View File
@@ -297,7 +297,7 @@ func TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = float64(common.MaxQuota)
common.QuotaPerUnit = float64(common.MaxWalletQuota + 1)
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 505, 3)
@@ -323,15 +323,15 @@ func TestRechargeEpayEnforcesFinalWalletQuotaLimit(t *testing.T) {
}{
{
name: "allows exact highest representable wallet balance",
currentQuota: common.MaxQuota - 1 - 1_000_000,
wantQuota: common.MaxQuota - 1,
currentQuota: common.MaxWalletQuota - 1_000_000,
wantQuota: common.MaxWalletQuota,
wantStatus: common.TopUpStatusSuccess,
},
{
name: "rejects balance above int32 quota domain",
currentQuota: common.MaxQuota - 1_000_000,
name: "rejects balance above wallet quota domain",
currentQuota: common.MaxWalletQuota - 999_999,
wantErr: true,
wantQuota: common.MaxQuota - 1_000_000,
wantQuota: common.MaxWalletQuota - 999_999,
wantStatus: common.TopUpStatusPending,
},
}
+33
View File
@@ -1,6 +1,7 @@
package model
import (
"math"
"testing"
"time"
@@ -137,6 +138,38 @@ func TestRedisBatchReserveNeverFallsBackToStaleDatabaseBalance(t *testing.T) {
assert.Equal(t, 7, reloadedToken.UsedQuota)
}
func TestBatchUpdateAccumulatesTwoMaximumRequestCharges(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
common.BatchUpdateEnabled = true
user := createReserveTestUser(t, common.MaxQuota*2+100)
require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
batchUpdate()
assert.Equal(t, 100, getUserQuotaFromDB(t, user.Id))
}
func TestBatchUpdateAccumulatorSaturatesOverflow(t *testing.T) {
resetBatchUpdateTestState(t)
addNewRecord(BatchUpdateTypeUserQuota, 1, math.MaxInt)
addNewRecord(BatchUpdateTypeUserQuota, 1, 1)
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
assert.Equal(t, math.MaxInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
batchUpdateStores[BatchUpdateTypeUserQuota] = make(map[int]int)
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
addNewRecord(BatchUpdateTypeUserQuota, 1, math.MinInt)
addNewRecord(BatchUpdateTypeUserQuota, 1, -1)
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
assert.Equal(t, math.MinInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
}
func TestReserveFallsBackToDatabaseWhenRedisIsUnavailable(t *testing.T) {
truncateTables(t)
resetBatchUpdateTestState(t)
+13 -1
View File
@@ -175,7 +175,7 @@ func Redeem(key string, userId int) (quota int, err error) {
if result.RowsAffected == 0 {
return errors.New("该兑换码已被使用")
}
return tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
return creditTopUpQuota(tx, userId, redemption.Quota, nil)
})
if err != nil {
common.SysError("redemption failed: " + err.Error())
@@ -187,6 +187,12 @@ func Redeem(key string, userId int) (quota int, err error) {
}
func (redemption *Redemption) Insert() error {
if redemption.Quota <= 0 {
return errors.New("redemption quota must be positive")
}
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
return err
}
var err error
err = DB.Create(redemption).Error
return err
@@ -199,6 +205,12 @@ func (redemption *Redemption) SelectUpdate() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
if redemption.Quota <= 0 {
return errors.New("redemption quota must be positive")
}
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
return err
}
var err error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
return err
+29
View File
@@ -148,6 +148,35 @@ func TestRedeemCreditsQuotaExactlyOnce(t *testing.T) {
assert.Equal(t, 500, user.Quota)
}
func TestRedeemRejectsWalletOverflow(t *testing.T) {
userId, key := setupRedeemFixture(t, 11)
require.NoError(t, DB.Model(&User{}).Where("id = ?", userId).Update("quota", common.MaxWalletQuota-10).Error)
_, err := Redeem(key, userId)
require.ErrorIs(t, err, ErrRedeemFailed)
var user User
require.NoError(t, DB.First(&user, "id = ?", userId).Error)
assert.Equal(t, common.MaxWalletQuota-10, user.Quota)
var redemption Redemption
require.NoError(t, DB.First(&redemption, "key = ?", key).Error)
assert.Equal(t, common.RedemptionCodeStatusEnabled, redemption.Status)
}
func TestRedemptionQuotaRejectsWalletOverflow(t *testing.T) {
setupRedeemFixture(t, 500)
redemption := &Redemption{
Name: "overflow-redemption",
Key: "10000000000000000000000000000002",
Status: common.RedemptionCodeStatusEnabled,
Quota: common.MaxWalletQuota + 1,
CreatedTime: common.GetTimestamp(),
}
require.Error(t, redemption.Insert())
}
// Exactly one of several concurrent redeems of the same code may win, and
// quota must be credited exactly once.
func TestRedeemConcurrentSingleSuccess(t *testing.T) {
+1 -1
View File
@@ -749,7 +749,7 @@ func calcSubscriptionBalanceQuota(priceAmount float64) (int, error) {
quota := decimal.NewFromFloat(priceAmount).
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
Ceil()
return common.QuotaFromDecimalStrict(quota)
return common.WalletQuotaFromDecimalStrict(quota)
}
// PurchaseSubscriptionWithBalance creates a subscription by deducting the user's wallet quota.
+17 -16
View File
@@ -42,11 +42,12 @@ const (
)
var (
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")
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")
ErrWalletQuotaLimitExceeded = errors.New("wallet quota limit exceeded")
)
func (topUp *TopUp) Insert() error {
@@ -56,10 +57,10 @@ func (topUp *TopUp) Insert() error {
}
func topUpQuotaMaxCurrent(creditedQuota int) (int, error) {
if creditedQuota <= 0 || creditedQuota >= common.MaxQuota {
if creditedQuota <= 0 || creditedQuota > common.MaxWalletQuota {
return 0, ErrInvalidTopUpQuota
}
return common.MaxQuota - 1 - creditedQuota, nil
return common.MaxWalletQuota - creditedQuota, nil
}
// ValidateTopUpQuotaCapacity performs the user-facing pre-payment check. The
@@ -81,8 +82,8 @@ func ValidateTopUpQuotaCapacity(userId int, creditedQuota int) error {
return nil
}
// creditTopUpQuota atomically enforces the int32 wallet ceiling while adding
// quota. Keeping the predicate and increment in one UPDATE prevents two
// creditTopUpQuota atomically enforces the 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)
@@ -203,7 +204,7 @@ func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (
topUp.PaymentMethod = actualPaymentMethod
}
var quotaErr error
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if quotaErr != nil || quotaToAdd <= 0 {
@@ -266,7 +267,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
return err
}
quota, err = common.QuotaFromDecimalStrict(
quota, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quota <= 0 {
@@ -482,11 +483,11 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
var quotaErr error
if topUp.PaymentProvider == PaymentProviderStripe {
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
} else {
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
}
@@ -556,7 +557,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
}
// Creem 直接使用 Amount 作为充值额度(整数)
quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
quota, err = common.WalletQuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
if err != nil || quota <= 0 {
return ErrInvalidTopUpQuota
}
@@ -624,7 +625,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return errors.New("充值订单状态错误")
}
quotaToAdd, err = common.QuotaFromDecimalStrict(
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
@@ -684,7 +685,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
return errors.New("充值订单状态错误")
}
quotaToAdd, err = common.QuotaFromDecimalStrict(
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
+32 -10
View File
@@ -1271,25 +1271,47 @@ func IncreaseUserQuota(id int, quota int, db bool) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
if err := common.ValidateWalletQuota(quota); err != nil {
return err
}
if !db && common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, quota)
gopool.Go(func() {
if err := cacheIncrUserQuota(id, int64(quota)); err != nil {
common.SysLog("failed to increase user quota: " + err.Error())
}
})
return nil
}
if err := increaseUserQuota(id, quota); err != nil {
return err
}
gopool.Go(func() {
err := cacheIncrUserQuota(id, int64(quota))
if err != nil {
if err := cacheIncrUserQuota(id, int64(quota)); err != nil {
common.SysLog("failed to increase user quota: " + err.Error())
}
})
if !db && common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, quota)
return nil
}
return increaseUserQuota(id, quota)
return nil
}
func increaseUserQuota(id int, quota int) (err error) {
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error
if err != nil {
result := DB.Model(&User{}).
Where("id = ? AND quota <= ?", id, common.MaxWalletQuota-quota).
Update("quota", gorm.Expr("quota + ?", quota))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 1 {
return nil
}
var count int64
if err := DB.Model(&User{}).Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
return err
if count == 0 {
return gorm.ErrRecordNotFound
}
return ErrWalletQuotaLimitExceeded
}
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
+16 -3
View File
@@ -2,6 +2,8 @@ package model
import (
"errors"
"fmt"
"math"
"sync"
"time"
@@ -42,11 +44,22 @@ func InitBatchUpdater() {
func addNewRecord(type_ int, id int, value int) {
batchUpdateLocks[type_].Lock()
defer batchUpdateLocks[type_].Unlock()
if _, ok := batchUpdateStores[type_][id]; !ok {
old, ok := batchUpdateStores[type_][id]
if !ok {
batchUpdateStores[type_][id] = value
} else {
batchUpdateStores[type_][id] += value
return
}
sum := old + value
if (value > 0 && sum < old) || (value < 0 && sum > old) {
common.SysError(fmt.Sprintf("batch update overflow: type=%d id=%d old=%d value=%d", type_, id, old, value))
if value > 0 {
sum = math.MaxInt
} else {
sum = math.MinInt
}
}
batchUpdateStores[type_][id] = sum
}
func batchUpdate() {