fix(topup): guard wallet quota during recharge
This commit is contained in:
+30
-14
@@ -216,31 +216,47 @@ func getMaxTopUpAmount() int64 {
|
|||||||
return maxStoredAmount.IntPart()
|
return maxStoredAmount.IntPart()
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCreditedQuota(quota decimal.Decimal) error {
|
func validateCreditedQuota(quota decimal.Decimal) (int, error) {
|
||||||
value, err := common.QuotaFromDecimalStrict(quota)
|
value, err := common.QuotaFromDecimalStrict(quota)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("充值额度超出系统可表示范围")
|
return 0, errors.New("充值额度超出系统可表示范围")
|
||||||
}
|
}
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return errors.New("充值额度必须大于 0")
|
return 0, errors.New("充值额度必须大于 0")
|
||||||
}
|
}
|
||||||
return nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateTopUpQuota(amount int64) error {
|
func validateTopUpQuota(amount int64) (int, error) {
|
||||||
quota, err := getTopUpQuota(amount)
|
quota, err := getTopUpQuota(amount)
|
||||||
if err == nil && quota > 0 {
|
if err == nil && quota > 0 {
|
||||||
return nil
|
return quota, nil
|
||||||
}
|
}
|
||||||
maxAmount := getMaxTopUpAmount()
|
maxAmount := getMaxTopUpAmount()
|
||||||
if maxAmount > 0 && amount > maxAmount {
|
if maxAmount > 0 && amount > maxAmount {
|
||||||
return fmt.Errorf("单笔充值数量不能大于 %d", maxAmount)
|
return 0, fmt.Errorf("单笔充值数量不能大于 %d", maxAmount)
|
||||||
}
|
}
|
||||||
return errors.New("充值数量无效")
|
return 0, errors.New("充值数量无效")
|
||||||
}
|
}
|
||||||
|
|
||||||
func rejectInvalidTopUpQuota(c *gin.Context, amount int64) bool {
|
func rejectInvalidCreditedQuota(c *gin.Context, userId int, quota decimal.Decimal) bool {
|
||||||
if err := validateTopUpQuota(amount); err != nil {
|
creditedQuota, err := validateCreditedQuota(quota)
|
||||||
|
if err == nil {
|
||||||
|
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectInvalidTopUpQuota(c *gin.Context, userId int, amount int64) bool {
|
||||||
|
creditedQuota, err := validateTopUpQuota(amount)
|
||||||
|
if err == nil {
|
||||||
|
err = model.ValidateTopUpQuotaCapacity(userId, creditedQuota)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -258,11 +274,11 @@ func RequestEpay(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rejectInvalidTopUpQuota(c, req.Amount) {
|
id := c.GetInt("id")
|
||||||
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||||
@@ -475,10 +491,10 @@ func RequestAmount(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rejectInvalidTopUpQuota(c, req.Amount) {
|
id := c.GetInt("id")
|
||||||
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := c.GetInt("id")
|
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||||
|
|||||||
@@ -97,13 +97,17 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateCreditedQuota(decimal.NewFromInt(selectedProduct.Quota)); err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
id := c.GetInt("id")
|
||||||
|
if rejectInvalidCreditedQuota(c, id, decimal.NewFromInt(selectedProduct.Quota)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
user, err := model.GetUserById(id, false)
|
||||||
user, _ := model.GetUserById(id, false)
|
if err != nil || user == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 生成唯一的订单引用ID
|
// 生成唯一的订单引用ID
|
||||||
reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTopUpQuotaValidation(t *testing.T) {
|
func TestTopUpQuotaValidation(t *testing.T) {
|
||||||
@@ -84,8 +87,9 @@ func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) {
|
|||||||
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||||
Floor().IntPart()
|
Floor().IntPart()
|
||||||
|
|
||||||
require.NoError(t, validateTopUpQuota(maxAmount))
|
_, err := validateTopUpQuota(maxAmount)
|
||||||
err := validateTopUpQuota(maxAmount + 1)
|
require.NoError(t, err)
|
||||||
|
_, err = validateTopUpQuota(maxAmount + 1)
|
||||||
require.EqualError(t, err, "单笔充值数量不能大于 4294")
|
require.EqualError(t, err, "单笔充值数量不能大于 4294")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +119,60 @@ func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
|||||||
assert.JSONEq(t, `{"message":"error","data":"单笔充值数量不能大于 4294"}`, recorder.Body.String())
|
assert.JSONEq(t, `{"message":"error","data":"单笔充值数量不能大于 4294"}`, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||||
|
oldQuotaPerUnit := common.QuotaPerUnit
|
||||||
|
oldDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
|
||||||
|
oldDB := model.DB
|
||||||
|
common.QuotaPerUnit = 500000
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = operation_setting.QuotaDisplayTypeUSD
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.AutoMigrate(&model.User{}))
|
||||||
|
model.DB = db
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.QuotaPerUnit = oldQuotaPerUnit
|
||||||
|
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
|
||||||
|
model.DB = oldDB
|
||||||
|
sqlDB, dbErr := db.DB()
|
||||||
|
if dbErr == nil {
|
||||||
|
require.NoError(t, sqlDB.Close())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, model.DB.Create(&model.User{
|
||||||
|
Id: 42,
|
||||||
|
Username: "topup_capacity_user",
|
||||||
|
Quota: 1_000_000,
|
||||||
|
Status: common.UserStatusEnabled,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set("id", 42)
|
||||||
|
ctx.Request = httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/user/amount",
|
||||||
|
strings.NewReader(`{"amount":4294}`),
|
||||||
|
)
|
||||||
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
RequestAmount(ctx)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.JSONEq(t, `{"message":"error","data":"top-up quota limit exceeded"}`, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
|
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
|
||||||
require.NoError(t, validateCreditedQuota(decimal.NewFromInt(common.MaxQuota-1)))
|
_, err := validateCreditedQuota(decimal.NewFromInt(common.MaxQuota - 1))
|
||||||
require.EqualError(t, validateCreditedQuota(decimal.Zero), "充值额度必须大于 0")
|
require.NoError(t, err)
|
||||||
|
_, err = validateCreditedQuota(decimal.Zero)
|
||||||
|
require.EqualError(t, err, "充值额度必须大于 0")
|
||||||
|
_, err = validateCreditedQuota(decimal.NewFromInt(common.MaxQuota))
|
||||||
require.EqualError(
|
require.EqualError(
|
||||||
t,
|
t,
|
||||||
validateCreditedQuota(decimal.NewFromInt(common.MaxQuota)),
|
err,
|
||||||
"充值额度超出系统可表示范围",
|
"充值额度超出系统可表示范围",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -135,8 +187,10 @@ func TestStripeCreditedQuotaIncludesGroupRatio(t *testing.T) {
|
|||||||
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(oldTopupGroupRatio))
|
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(oldTopupGroupRatio))
|
||||||
})
|
})
|
||||||
|
|
||||||
require.NoError(t, validateCreditedQuota(getStripeCreditedQuota(2147, "vip")))
|
_, err := validateCreditedQuota(getStripeCreditedQuota(2147, "vip"))
|
||||||
require.Error(t, validateCreditedQuota(getStripeCreditedQuota(2148, "vip")))
|
require.NoError(t, err)
|
||||||
|
_, err = validateCreditedQuota(getStripeCreditedQuota(2148, "vip"))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"free":0}`))
|
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"free":0}`))
|
||||||
assert.True(t, decimal.NewFromInt(500000).Equal(getStripeCreditedQuota(1, "free")))
|
assert.True(t, decimal.NewFromInt(500000).Equal(getStripeCreditedQuota(1, "free")))
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateCreditedQuota(getStripeCreditedQuota(req.Amount, group)); err != nil {
|
if rejectInvalidCreditedQuota(c, id, getStripeCreditedQuota(req.Amount, group)) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payMoney := getStripePayMoney(float64(req.Amount), group)
|
payMoney := getStripePayMoney(float64(req.Amount), group)
|
||||||
@@ -95,12 +94,15 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
id := c.GetInt("id")
|
||||||
user, _ := model.GetUserById(id, false)
|
user, err := model.GetUserById(id, false)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
|
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
|
||||||
if err := validateCreditedQuota(
|
if rejectInvalidCreditedQuota(c, id,
|
||||||
decimal.NewFromFloat(chargedMoney).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
decimal.NewFromFloat(chargedMoney).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||||
); err != nil {
|
) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,11 +123,11 @@ func RequestWaffoAmount(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rejectInvalidTopUpQuota(c, req.Amount) {
|
id := c.GetInt("id")
|
||||||
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||||
@@ -160,11 +160,11 @@ func RequestWaffoPay(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rejectInvalidTopUpQuota(c, req.Amount) {
|
id := c.GetInt("id")
|
||||||
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
|
||||||
user, err := model.GetUserById(id, false)
|
user, err := model.GetUserById(id, false)
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ func RequestWaffoPancakeAmount(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rejectInvalidTopUpQuota(c, req.Amount) {
|
id := c.GetInt("id")
|
||||||
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
|
||||||
group, err := model.GetUserGroup(id, true)
|
group, err := model.GetUserGroup(id, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||||
@@ -354,11 +354,11 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rejectInvalidTopUpQuota(c, req.Amount) {
|
id := c.GetInt("id")
|
||||||
|
if rejectInvalidTopUpQuota(c, id, req.Amount) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := c.GetInt("id")
|
|
||||||
user, err := model.GetUserById(id, false)
|
user, err := model.GetUserById(id, false)
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||||
|
|||||||
@@ -308,3 +308,48 @@ func TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder(t *testing.T) {
|
|||||||
assert.Equal(t, 3, getUserQuotaForPaymentGuardTest(t, user.Id))
|
assert.Equal(t, 3, getUserQuotaForPaymentGuardTest(t, user.Id))
|
||||||
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, order.TradeNo))
|
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, order.TradeNo))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRechargeEpayEnforcesFinalWalletQuotaLimit(t *testing.T) {
|
||||||
|
oldQuotaPerUnit := common.QuotaPerUnit
|
||||||
|
common.QuotaPerUnit = 500000
|
||||||
|
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
currentQuota int
|
||||||
|
wantErr bool
|
||||||
|
wantQuota int
|
||||||
|
wantStatus string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "allows exact highest representable wallet balance",
|
||||||
|
currentQuota: common.MaxQuota - 1 - 1_000_000,
|
||||||
|
wantQuota: common.MaxQuota - 1,
|
||||||
|
wantStatus: common.TopUpStatusSuccess,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rejects balance above int32 quota domain",
|
||||||
|
currentQuota: common.MaxQuota - 1_000_000,
|
||||||
|
wantErr: true,
|
||||||
|
wantQuota: common.MaxQuota - 1_000_000,
|
||||||
|
wantStatus: common.TopUpStatusPending,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
truncateTables(t)
|
||||||
|
user := insertUserForPaymentGuardTest(t, 506, tc.currentQuota)
|
||||||
|
order := createEpayTestOrder(t, user.Id, "EPAYTESTWALLETLIMIT", PaymentProviderEpay, common.TopUpStatusPending)
|
||||||
|
|
||||||
|
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
|
||||||
|
if tc.wantErr {
|
||||||
|
require.ErrorIs(t, err, ErrTopUpQuotaLimitExceeded)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tc.wantQuota, getUserQuotaForPaymentGuardTest(t, user.Id))
|
||||||
|
assert.Equal(t, tc.wantStatus, getTopUpStatusForPaymentGuardTest(t, order.TradeNo))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+81
-26
@@ -42,9 +42,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
|
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
|
||||||
ErrTopUpNotFound = errors.New("topup not found")
|
ErrTopUpNotFound = errors.New("topup not found")
|
||||||
ErrTopUpStatusInvalid = errors.New("topup status invalid")
|
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 {
|
func (topUp *TopUp) Insert() error {
|
||||||
@@ -53,6 +55,67 @@ func (topUp *TopUp) Insert() error {
|
|||||||
return err
|
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 {
|
func (topUp *TopUp) Update() error {
|
||||||
var err error
|
var err error
|
||||||
err = DB.Save(topUp).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)),
|
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||||
)
|
)
|
||||||
if quotaErr != nil || quotaToAdd <= 0 {
|
if quotaErr != nil || quotaToAdd <= 0 {
|
||||||
return errors.New("无效的充值额度")
|
return ErrInvalidTopUpQuota
|
||||||
}
|
}
|
||||||
topUp.CompleteTime = common.GetTimestamp()
|
topUp.CompleteTime = common.GetTimestamp()
|
||||||
topUp.Status = common.TopUpStatusSuccess
|
topUp.Status = common.TopUpStatusSuccess
|
||||||
if err := tx.Save(topUp).Error; err != nil {
|
if err := tx.Save(topUp).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd))
|
return creditTopUpQuota(tx, topUp.UserId, quotaToAdd, nil)
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
if result.RowsAffected != 1 {
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, ErrTopUpNotFound) && !errors.Is(err, ErrPaymentMethodMismatch) && !errors.Is(err, ErrTopUpStatusInvalid) {
|
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)),
|
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||||
)
|
)
|
||||||
if err != nil || quota <= 0 {
|
if err != nil || quota <= 0 {
|
||||||
return errors.New("无效的充值额度")
|
return ErrInvalidTopUpQuota
|
||||||
}
|
}
|
||||||
return tx.Model(&User{}).Where("id = ?", topUp.UserId).
|
return creditTopUpQuota(tx, topUp.UserId, quota, map[string]interface{}{
|
||||||
Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
|
"stripe_customer": customerId,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -434,7 +491,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if quotaErr != nil || quotaToAdd <= 0 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,13 +558,11 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
|||||||
// Creem 直接使用 Amount 作为充值额度(整数)
|
// Creem 直接使用 Amount 作为充值额度(整数)
|
||||||
quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
|
quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
|
||||||
if err != nil || quota <= 0 {
|
if err != nil || quota <= 0 {
|
||||||
return errors.New("无效的充值额度")
|
return ErrInvalidTopUpQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
|
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
|
||||||
updateFields := map[string]interface{}{
|
updateFields := map[string]interface{}{}
|
||||||
"quota": gorm.Expr("quota + ?", quota),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
|
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
|
||||||
if customerEmail != "" {
|
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 {
|
if err != nil {
|
||||||
@@ -573,7 +628,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
|||||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||||
)
|
)
|
||||||
if err != nil || quotaToAdd <= 0 {
|
if err != nil || quotaToAdd <= 0 {
|
||||||
return errors.New("无效的充值额度")
|
return ErrInvalidTopUpQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
topUp.CompleteTime = common.GetTimestamp()
|
topUp.CompleteTime = common.GetTimestamp()
|
||||||
@@ -582,7 +637,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
@@ -633,7 +688,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
|
|||||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||||
)
|
)
|
||||||
if err != nil || quotaToAdd <= 0 {
|
if err != nil || quotaToAdd <= 0 {
|
||||||
return errors.New("无效的充值额度")
|
return ErrInvalidTopUpQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
topUp.CompleteTime = common.GetTimestamp()
|
topUp.CompleteTime = common.GetTimestamp()
|
||||||
@@ -642,7 +697,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user