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
+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))