fix(topup): settle recharge orders atomically

This commit is contained in:
CaIon
2026-08-11 22:03:47 +08:00
parent d7992672a6
commit 50e5377ea5
7 changed files with 367 additions and 82 deletions
+6
View File
@@ -146,3 +146,9 @@ func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
f, _ := d.Round(0).Float64()
return saturateQuota(f, "QuotaFromDecimal")
}
// QuotaFromDecimalStrict converts an in-range decimal quota and rejects a
// value that would otherwise be saturated at the database's int32 boundary.
func QuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
return strictQuota(QuotaFromDecimalChecked(d))
}
+29 -40
View File
@@ -1,6 +1,7 @@
package controller
import (
"errors"
"fmt"
"net/http"
"net/url"
@@ -181,7 +182,7 @@ func getMinTopup() int64 {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
minTopup = common.QuotaFromDecimal(dMinTopup.Mul(dQuotaPerUnit))
}
return int64(minTopup)
}
@@ -351,16 +352,9 @@ func EpayNotify(c *gin.Context) {
return
}
verifyInfo, err := client.Verify(params)
if err == nil && verifyInfo.VerifyStatus {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
_, err := c.Writer.Write([]byte("success"))
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
}
} else {
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
if err != nil || !verifyInfo.VerifyStatus {
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), writeErr.Error()))
}
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
@@ -369,46 +363,41 @@ func EpayNotify(c *gin.Context) {
}
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
// 进程内锁只是优化;重复/并发回调的正确性由 RechargeEpay 的
// 数据库行锁 + 事务内状态校验保证(多实例部署下同样安全)。
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo)
if topUp == nil {
alreadyDone, err := model.RechargeEpay(verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP())
if err != nil {
switch {
case errors.Is(err, model.ErrTopUpNotFound):
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
case errors.Is(err, model.ErrPaymentMethodMismatch):
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付网关不匹配 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
case errors.Is(err, model.ErrTopUpStatusInvalid):
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单状态非法 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
default:
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 充值处理失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
}
if _, writeErr := c.Writer.Write([]byte("fail")); writeErr != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
}
return
}
if topUp.PaymentProvider != model.PaymentProviderEpay {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付网关不匹配 trade_no=%s order_provider=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentProvider, verifyInfo.Type, c.ClientIP()))
return
}
if topUp.Status == common.TopUpStatusPending {
if topUp.PaymentMethod != verifyInfo.Type {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 实际支付方式与订单不同 trade_no=%s order_payment_method=%s actual_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
topUp.PaymentMethod = verifyInfo.Type
}
topUp.Status = common.TopUpStatusSuccess
err := topUp.Update()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
return
}
//user, _ := model.GetUserById(topUp.UserId, false)
//user.Quota += topUp.Amount * 500000
dAmount := decimal.NewFromInt(int64(topUp.Amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
if alreadyDone {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 重复回调幂等忽略 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
} else {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP()))
}
} else {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
}
if _, writeErr := c.Writer.Write([]byte("success")); writeErr != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), writeErr.Error()))
}
}
func RequestAmount(c *gin.Context) {
+137 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) {
func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) *User {
t.Helper()
user := &User{
Id: id,
@@ -18,6 +18,7 @@ func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) {
Quota: quota,
}
require.NoError(t, DB.Create(user).Error)
return user
}
func insertSubscriptionPlanForPaymentGuardTest(t *testing.T, id int) *SubscriptionPlan {
@@ -172,3 +173,138 @@ func TestExpireSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T)
require.NotNil(t, order)
assert.Equal(t, common.TopUpStatusPending, order.Status)
}
func createEpayTestOrder(t *testing.T, userId int, tradeNo string, provider string, status string) TopUp {
t.Helper()
topUp := TopUp{
UserId: userId,
Amount: 2,
Money: 10.0,
TradeNo: tradeNo,
PaymentMethod: "alipay",
PaymentProvider: provider,
CreateTime: common.GetTimestamp(),
Status: status,
}
require.NoError(t, DB.Create(&topUp).Error)
return topUp
}
func TestRechargeEpayCreditsQuotaExactlyOnce(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 501, 0)
order := createEpayTestOrder(t, user.Id, "EPAYTESTONCE", PaymentProviderEpay, common.TopUpStatusPending)
alreadyDone, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.False(t, alreadyDone)
assert.Equal(t, 2*500000, getUserQuotaForPaymentGuardTest(t, user.Id))
reloaded := GetTopUpByTradeNo(order.TradeNo)
require.NotNil(t, reloaded)
assert.Equal(t, common.TopUpStatusSuccess, reloaded.Status)
assert.NotZero(t, reloaded.CompleteTime)
alreadyDone, err = RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.True(t, alreadyDone)
assert.Equal(t, 2*500000, getUserQuotaForPaymentGuardTest(t, user.Id))
}
func TestRechargeEpayKeepsRedisAndDatabaseCreditInSync(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 5
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 502, 7)
require.NoError(t, populateUserCache(*user))
order := createEpayTestOrder(t, user.Id, "EPAYTESTREDISSYNC", PaymentProviderEpay, common.TopUpStatusPending)
alreadyDone, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.False(t, alreadyDone)
assert.Equal(t, 17, getUserQuotaForPaymentGuardTest(t, user.Id))
cached, err := cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, 17, cached.Quota)
alreadyDone, err = RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.NoError(t, err)
assert.True(t, alreadyDone)
cached, err = cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, 17, cached.Quota)
}
func TestRechargeEpayUpdatesPaymentMethodToActual(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 503, 0)
order := createEpayTestOrder(t, user.Id, "EPAYTESTMETHOD", PaymentProviderEpay, common.TopUpStatusPending)
alreadyDone, err := RechargeEpay(order.TradeNo, "wxpay", "127.0.0.1")
require.NoError(t, err)
assert.False(t, alreadyDone)
reloaded := GetTopUpByTradeNo(order.TradeNo)
require.NotNil(t, reloaded)
assert.Equal(t, "wxpay", reloaded.PaymentMethod)
assert.Equal(t, 2*500000, getUserQuotaForPaymentGuardTest(t, user.Id))
}
func TestRechargeEpayRejectsForeignAndNonPendingOrders(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 504, 7)
t.Run("order from another payment provider", func(t *testing.T) {
order := createEpayTestOrder(t, user.Id, "EPAYTESTSTRIPE", PaymentProviderStripe, common.TopUpStatusPending)
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
assert.ErrorIs(t, err, ErrPaymentMethodMismatch)
assert.Equal(t, 7, getUserQuotaForPaymentGuardTest(t, user.Id))
})
t.Run("order that is not pending", func(t *testing.T) {
order := createEpayTestOrder(t, user.Id, "EPAYTESTEXPIRED", PaymentProviderEpay, common.TopUpStatusExpired)
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
assert.ErrorIs(t, err, ErrTopUpStatusInvalid)
assert.Equal(t, 7, getUserQuotaForPaymentGuardTest(t, user.Id))
})
t.Run("missing order", func(t *testing.T) {
_, err := RechargeEpay("EPAYTESTMISSING", "alipay", "127.0.0.1")
assert.ErrorIs(t, err, ErrTopUpNotFound)
})
}
func TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder(t *testing.T) {
truncateTables(t)
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = float64(common.MaxQuota)
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
user := insertUserForPaymentGuardTest(t, 505, 3)
order := createEpayTestOrder(t, user.Id, "EPAYTESTOVERFLOW", PaymentProviderEpay, common.TopUpStatusPending)
_, err := RechargeEpay(order.TradeNo, "alipay", "127.0.0.1")
require.Error(t, err)
assert.Equal(t, 3, getUserQuotaForPaymentGuardTest(t, user.Id))
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, order.TradeNo))
}
+68
View File
@@ -0,0 +1,68 @@
package model
import (
"context"
"github.com/QuantumNous/new-api/common"
)
type cacheQuotaResult int
const (
cacheQuotaInsufficient cacheQuotaResult = iota
cacheQuotaOK
cacheQuotaMiss
)
// 下列脚本都是守卫式的:只在完整哈希(Id 匹配且配额字段存在)上操作,
// 哈希缺失时返回 miss 而不是创建残缺哈希。脚本不修改 TTL(HINCRBY 天然保留
// 水合时设置的 TTL),因此即使某个写库路径绕过了缓存,偏差也会在一个 TTL
// 窗口内随缓存过期而自愈。
const userQuotaReserveScript = `
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1
end
local quota = tonumber(redis.call('HGET', KEYS[1], 'Quota'))
if quota == nil or quota < tonumber(ARGV[1]) then
return 0
end
redis.call('HINCRBY', KEYS[1], 'Quota', -tonumber(ARGV[1]))
return 1`
const userQuotaDeltaScript = `
if tonumber(redis.call('HGET', KEYS[1], 'Id') or '0') ~= tonumber(ARGV[2])
or tonumber(redis.call('HGET', KEYS[1], 'CacheSchema') or '0') ~= tonumber(ARGV[3])
or redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
return -1
end
redis.call('HINCRBY', KEYS[1], 'Quota', tonumber(ARGV[1]))
return 1`
func quotaResultFromLua(result int, err error) (cacheQuotaResult, error) {
if err != nil {
return cacheQuotaMiss, err
}
switch result {
case 1:
return cacheQuotaOK, nil
case 0:
return cacheQuotaInsufficient, nil
default:
return cacheQuotaMiss, nil
}
}
func cacheTryReserveUserQuota(userID int, amount int64) (cacheQuotaResult, error) {
result, err := common.RDB.Eval(context.Background(), userQuotaReserveScript,
[]string{getUserCacheKey(userID)}, amount, userID, userCacheSchemaVersion).Int()
return quotaResultFromLua(result, err)
}
func cacheApplyUserQuotaDelta(userID int, delta int64) (cacheQuotaResult, error) {
result, err := common.RDB.Eval(context.Background(), userQuotaDeltaScript,
[]string{getUserCacheKey(userID)}, delta, userID, userCacheSchemaVersion).Int()
return quotaResultFromLua(result, err)
}
+1
View File
@@ -181,6 +181,7 @@ func Redeem(key string, userId int) (quota int, err error) {
common.SysError("redemption failed: " + err.Error())
return 0, ErrRedeemFailed
}
syncCreditUserQuotaCache(userId, redemption.Quota, "redemption")
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
return redemption.Quota, nil
}
+108 -38
View File
@@ -106,12 +106,82 @@ func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, ta
})
}
// RechargeEpay 原子完成易支付订单:订单行锁、状态校验、成功更新与用户额度增加
// 在同一个事务内完成,因此同一订单的并发/重复回调(包括多实例部署下)最多充值一次。
// alreadyDone=true 表示订单此前已完成,本次为幂等重复回调。
// 进程内的 LockOrder 只是优化,正确性由本函数的数据库行锁保证。
func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (alreadyDone bool, err error) {
if tradeNo == "" {
return false, errors.New("未提供支付单号")
}
refCol := "`trade_no`"
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
refCol = `"trade_no"`
}
var quotaToAdd int
topUp := &TopUp{}
err = DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return ErrTopUpNotFound
}
if topUp.PaymentProvider != PaymentProviderEpay {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
alreadyDone = true
return nil
}
if topUp.Status != common.TopUpStatusPending {
return ErrTopUpStatusInvalid
}
if actualPaymentMethod != "" && topUp.PaymentMethod != actualPaymentMethod {
topUp.PaymentMethod = actualPaymentMethod
}
var quotaErr error
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if quotaErr != nil || quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
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
})
if err != nil {
if !errors.Is(err, ErrTopUpNotFound) && !errors.Is(err, ErrPaymentMethodMismatch) && !errors.Is(err, ErrTopUpStatusInvalid) {
common.SysError("epay topup failed: " + err.Error())
}
return false, err
}
if alreadyDone {
return true, nil
}
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, "epay topup")
common.SysLog(fmt.Sprintf("易支付充值成功 trade_no=%s user_id=%d quota_to_add=%d money=%.2f", topUp.TradeNo, topUp.UserId, quotaToAdd, topUp.Money))
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentProviderEpay)
return false, nil
}
func Recharge(referenceId string, customerId string, callerIp string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
var quota float64
var quota int
topUp := &TopUp{}
refCol := "`trade_no`"
@@ -140,21 +210,23 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
return err
}
quota = topUp.Money * common.QuotaPerUnit
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
if err != nil {
return err
quota, err = common.QuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quota <= 0 {
return errors.New("无效的充值额度")
}
return nil
return tx.Model(&User{}).Where("id = ?", topUp.UserId).
Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
})
if err != nil {
common.SysError("topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quota, "stripe topup")
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(quota), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
return nil
}
@@ -351,15 +423,17 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
// 计算应充值额度:
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
var quotaErr error
if topUp.PaymentProvider == PaymentProviderStripe {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
} else {
dAmount := decimal.NewFromInt(topUp.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
}
if quotaToAdd <= 0 {
if quotaErr != nil || quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
@@ -386,6 +460,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
}
// 事务外记录日志,避免阻塞
syncCreditUserQuotaCache(userId, quotaToAdd, "manual topup")
RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin")
return nil
}
@@ -394,7 +469,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("未提供支付单号")
}
var quota int64
var quota int
topUp := &TopUp{}
refCol := "`trade_no`"
@@ -424,7 +499,10 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
}
// Creem 直接使用 Amount 作为充值额度(整数)
quota = topUp.Amount
quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
if err != nil || quota <= 0 {
return errors.New("无效的充值额度")
}
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
updateFields := map[string]interface{}{
@@ -446,18 +524,14 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
}
}
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
if err != nil {
return err
}
return nil
return tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
})
if err != nil {
common.SysError("creem topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quota, "creem topup")
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem)
@@ -495,10 +569,10 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return errors.New("充值订单状态错误")
}
dAmount := decimal.NewFromInt(topUp.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
if quotaToAdd <= 0 {
quotaToAdd, err = common.QuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
@@ -508,17 +582,14 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return err
}
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
return nil
return tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error
})
if err != nil {
common.SysError("waffo topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, "waffo topup")
if quotaToAdd > 0 {
RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo)
@@ -558,8 +629,10 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
return errors.New("充值订单状态错误")
}
quotaToAdd = int(decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart())
if quotaToAdd <= 0 {
quotaToAdd, err = common.QuotaFromDecimalStrict(
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
)
if err != nil || quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
@@ -569,17 +642,14 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
return err
}
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
return nil
return tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error
})
if err != nil {
common.SysError("waffo pancake topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
syncCreditUserQuotaCache(topUp.UserId, quotaToAdd, "waffo pancake topup")
if quotaToAdd > 0 {
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
+17 -2
View File
@@ -143,18 +143,33 @@ func cacheGetUserBase(userId int) (*UserBase, error) {
return &userCache, nil
}
// Add atomic quota operations using hash fields
// Add atomic quota operations using hash fields.
// 通过守卫式 Lua 脚本执行:哈希不存在时直接跳过(下次读取会从数据库水合),
// 不会像裸 HINCRBY 那样创建只含 Quota 字段的残缺哈希。
func cacheIncrUserQuota(userId int, delta int64) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHIncrBy(getUserCacheKey(userId), "Quota", delta)
_, err := cacheApplyUserQuotaDelta(userId, delta)
return err
}
func cacheDecrUserQuota(userId int, delta int64) error {
return cacheIncrUserQuota(userId, -delta)
}
// syncCreditUserQuotaCache 在授信事务(充值/兑换等)提交后同步把增量补进缓存
// 余额。预扣以缓存值为准(存在期间),授信不能绕过它,否则新到账的额度在
// 缓存过期前不可用;缓存未命中无需处理,下次读取会从已提交的数据库余额水合。
func syncCreditUserQuotaCache(userId int, quota int, operation string) {
if quota <= 0 {
return
}
if err := cacheIncrUserQuota(userId, int64(quota)); err != nil {
common.SysLog(fmt.Sprintf("failed to sync %s credit to user quota cache: %s", operation, err.Error()))
}
}
// Helper functions to get individual fields if needed
func getUserGroupCache(userId int) (string, error) {
cache, err := GetUserCache(userId)