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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user