fix(topup): guard wallet quota during recharge
This commit is contained in:
+81
-26
@@ -42,9 +42,11 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
|
||||
ErrTopUpNotFound = errors.New("topup not found")
|
||||
ErrTopUpStatusInvalid = errors.New("topup status invalid")
|
||||
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")
|
||||
)
|
||||
|
||||
func (topUp *TopUp) Insert() error {
|
||||
@@ -53,6 +55,67 @@ func (topUp *TopUp) Insert() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func topUpQuotaMaxCurrent(creditedQuota int) (int, error) {
|
||||
if creditedQuota <= 0 || creditedQuota >= common.MaxQuota {
|
||||
return 0, ErrInvalidTopUpQuota
|
||||
}
|
||||
return common.MaxQuota - 1 - creditedQuota, nil
|
||||
}
|
||||
|
||||
// ValidateTopUpQuotaCapacity performs the user-facing pre-payment check. The
|
||||
// settlement path repeats the same invariant with an atomic conditional
|
||||
// update, because the wallet balance can change after checkout creation.
|
||||
func ValidateTopUpQuotaCapacity(userId int, creditedQuota int) error {
|
||||
maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := DB.Select("quota").Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Quota > maxCurrentQuota {
|
||||
return ErrTopUpQuotaLimitExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// creditTopUpQuota atomically enforces the int32 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateFields := make(map[string]interface{}, len(updates)+1)
|
||||
for key, value := range updates {
|
||||
updateFields[key] = value
|
||||
}
|
||||
updateFields["quota"] = gorm.Expr("quota + ?", creditedQuota)
|
||||
|
||||
result := tx.Model(&User{}).
|
||||
Where("id = ? AND quota <= ?", userId, maxCurrentQuota).
|
||||
Updates(updateFields)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := tx.Model(&User{}).Where("id = ?", userId).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return ErrTopUpQuotaLimitExceeded
|
||||
}
|
||||
|
||||
func (topUp *TopUp) Update() error {
|
||||
var err error
|
||||
err = DB.Save(topUp).Error
|
||||
@@ -144,21 +207,14 @@ func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (
|
||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if quotaErr != nil || quotaToAdd <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
return ErrInvalidTopUpQuota
|
||||
}
|
||||
topUp.CompleteTime = common.GetTimestamp()
|
||||
topUp.Status = common.TopUpStatusSuccess
|
||||
if err := tx.Save(topUp).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrTopUpNotFound) && !errors.Is(err, ErrPaymentMethodMismatch) && !errors.Is(err, ErrTopUpStatusInvalid) {
|
||||
@@ -214,10 +270,11 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
|
||||
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil || quota <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
return ErrInvalidTopUpQuota
|
||||
}
|
||||
return tx.Model(&User{}).Where("id = ?", topUp.UserId).
|
||||
Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
|
||||
return creditTopUpQuota(tx, topUp.UserId, quota, map[string]interface{}{
|
||||
"stripe_customer": customerId,
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -434,7 +491,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
|
||||
)
|
||||
}
|
||||
if quotaErr != nil || quotaToAdd <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
return ErrInvalidTopUpQuota
|
||||
}
|
||||
|
||||
// 标记完成
|
||||
@@ -445,7 +502,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
|
||||
}
|
||||
|
||||
// 增加用户额度(立即写库,保持一致性)
|
||||
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
|
||||
if err := creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -501,13 +558,11 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
||||
// Creem 直接使用 Amount 作为充值额度(整数)
|
||||
quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
|
||||
if err != nil || quota <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
return ErrInvalidTopUpQuota
|
||||
}
|
||||
|
||||
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
|
||||
updateFields := map[string]interface{}{
|
||||
"quota": gorm.Expr("quota + ?", quota),
|
||||
}
|
||||
updateFields := map[string]interface{}{}
|
||||
|
||||
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
|
||||
if customerEmail != "" {
|
||||
@@ -524,7 +579,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
|
||||
return creditTopUpQuota(tx, topUp.UserId, quota, updateFields)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -573,7 +628,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil || quotaToAdd <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
return ErrInvalidTopUpQuota
|
||||
}
|
||||
|
||||
topUp.CompleteTime = common.GetTimestamp()
|
||||
@@ -582,7 +637,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error
|
||||
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -633,7 +688,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
|
||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil || quotaToAdd <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
return ErrInvalidTopUpQuota
|
||||
}
|
||||
|
||||
topUp.CompleteTime = common.GetTimestamp()
|
||||
@@ -642,7 +697,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error
|
||||
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user