From b514a0798bf52bb8ab856bcb903882d15781a8cc Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 16 Apr 2026 16:14:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(payment):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1/=E6=94=AF=E4=BB=98=E5=AE=9D=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=86=97=E4=BD=99=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取通用 calcPayMoney/calcMinTopup 函数,消除微信和支付宝控制器中的重复计费逻辑 - 合并 RechargeAlipay/RechargeWechat 为 rechargeByQRCodePayment 内部函数,删除 model/topup_alipay.go - 复用已有的 wrapAsPEM 替换支付宝专用的 wrapAlipayPublicKey - 删除被 QRCodePayModal 替代的 WechatPayQRCodeModal.jsx - 修复 controller/topup.go 中支付宝代码块的缩进错误 净减 264 行代码。 Co-Authored-By: Claude --- controller/topup.go | 62 +++++--- controller/topup_alipay.go | 73 ++-------- controller/topup_wechat.go | 26 +--- model/topup_alipay.go | 80 ----------- model/topup_wechat.go | 18 ++- .../components/topup/WechatPayQRCodeModal.jsx | 136 ------------------ web/src/components/topup/index.jsx | 1 - 7 files changed, 66 insertions(+), 330 deletions(-) delete mode 100644 model/topup_alipay.go delete mode 100644 web/src/components/topup/WechatPayQRCodeModal.jsx diff --git a/controller/topup.go b/controller/topup.go index 47e8d5c..156f05e 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -72,25 +72,25 @@ func GetTopUpInfo(c *gin.Context) { payMethods = append(payMethods, wechatMethod) } } - // 如果启用了支付宝支付,添加到支付方法列表 - if setting.IsAlipayConfigured() { - hasAlipay := false - for _, method := range payMethods { - if method["type"] == PaymentMethodAlipay { - hasAlipay = true - break - } + // 如果启用了支付宝支付,添加到支付方法列表 + if setting.IsAlipayConfigured() { + hasAlipay := false + for _, method := range payMethods { + if method["type"] == PaymentMethodAlipay { + hasAlipay = true + break } - if !hasAlipay { - alipayMethod := map[string]string{ - "name": "Alipay", - "type": PaymentMethodAlipay, - "color": "rgba(var(--semi-blue-5), 1)", - "min_topup": strconv.Itoa(setting.AlipayMinTopUp), - } - payMethods = append(payMethods, alipayMethod) + } + if !hasAlipay { + alipayMethod := map[string]string{ + "name": "Alipay", + "type": PaymentMethodAlipay, + "color": "rgba(var(--semi-blue-5), 1)", + "min_topup": strconv.Itoa(setting.AlipayMinTopUp), } + payMethods = append(payMethods, alipayMethod) } + } data := gin.H{ "enable_online_topup": enableOnlineTopup, @@ -164,15 +164,37 @@ func getPayMoney(amount int64, group string) float64 { } func getMinTopup() int64 { - minTopup := operation_setting.MinTopUp + return calcMinTopup(operation_setting.MinTopUp) +} + +// calcMinTopup 计算最低充值数量(考虑 QuotaDisplayType 换算) +func calcMinTopup(baseMinTopup int) int64 { + minTopup := baseMinTopup if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { - dMinTopup := decimal.NewFromInt(int64(minTopup)) - dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) - minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart()) + minTopup = minTopup * int(common.QuotaPerUnit) } return int64(minTopup) } +// calcPayMoney 计算应付金额(元),使用指定的单价和最低充值 +func calcPayMoney(amount float64, group string, unitPrice float64) float64 { + originalAmount := amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + amount = amount / common.QuotaPerUnit + } + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { + if ds > 0 { + discount = ds + } + } + return amount * unitPrice * topupGroupRatio * discount +} + func RequestEpay(c *gin.Context) { var req EpayRequest err := c.ShouldBindJSON(&req) diff --git a/controller/topup_alipay.go b/controller/topup_alipay.go index 18aea8e..b16822b 100644 --- a/controller/topup_alipay.go +++ b/controller/topup_alipay.go @@ -3,19 +3,16 @@ package controller import ( "context" "encoding/base64" - "encoding/pem" "fmt" "log" "net/http" "strconv" - "strings" "sync" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" - "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" "github.com/go-pay/gopay" @@ -64,8 +61,11 @@ func getAlipayClient() (*alipay.Client, error) { SetNotifyUrl(setting.AlipayNotifyURL) // 设置支付宝公钥(用于回调验签) - pubKeyPEM := wrapAlipayPublicKey(setting.AlipayPublicKey) - client.AutoVerifySign([]byte(pubKeyPEM)) + pubKeyBytes, err := base64.StdEncoding.DecodeString(setting.AlipayPublicKey) + if err != nil { + pubKeyBytes = []byte(setting.AlipayPublicKey) + } + client.AutoVerifySign([]byte(wrapAsPEM(pubKeyBytes, "PUBLIC KEY"))) alipayClient = client return alipayClient, nil @@ -107,7 +107,7 @@ func RequestAlipayPayAmount(c *gin.Context) { return } - minTopup := getAlipayMinTopup() + minTopup := calcMinTopup(setting.AlipayMinTopUp) if req.Amount < minTopup { c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", minTopup)}) return @@ -120,7 +120,7 @@ func RequestAlipayPayAmount(c *gin.Context) { return } - payMoney := getAlipayPayMoney(float64(req.Amount), group) + payMoney := calcPayMoney(float64(req.Amount), group, setting.AlipayUnitPrice) if payMoney <= 0.01 { c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) return @@ -137,7 +137,7 @@ func RequestAlipayPay(c *gin.Context) { return } - minTopup := getAlipayMinTopup() + minTopup := calcMinTopup(setting.AlipayMinTopUp) if req.Amount < minTopup { c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", minTopup)}) return @@ -175,8 +175,8 @@ func RequestAlipayPay(c *gin.Context) { tradeNo := fmt.Sprintf("ali%d%s", time.Now().UnixMilli(), randstr.String(8)) - payMoney := getAlipayPayMoney(float64(req.Amount), group) - totalAmount := strconv.FormatFloat(payMoney, 'f', 2, 64) // 支付宝金额单位是元 + payMoney := calcPayMoney(float64(req.Amount), group, setting.AlipayUnitPrice) + totalAmount := strconv.FormatFloat(payMoney, 'f', 2, 64) qrCode, err := createAlipayPrecreateOrder(client, fmt.Sprintf("充值%d", req.Amount), tradeNo, totalAmount) if err != nil { @@ -222,7 +222,6 @@ func AlipayPayStatus(c *gin.Context) { return } - // 验证订单属于当前用户 userId := c.GetInt("id") if topUp.UserId != userId { c.JSON(200, gin.H{"message": "error", "data": "订单不存在"}) @@ -261,7 +260,6 @@ func AlipayPayWebhook(c *gin.Context) { tradeStatus := notifyReq.Get("trade_status") if tradeStatus != "TRADE_SUCCESS" { - log.Printf("支付宝回调非成功状态: %s", tradeStatus) c.String(http.StatusOK, "success") return } @@ -280,54 +278,3 @@ func AlipayPayWebhook(c *gin.Context) { log.Printf("支付宝充值成功: %s", tradeNo) c.String(http.StatusOK, "success") } - -// getAlipayPayMoney 计算支付宝应付金额(元) -func getAlipayPayMoney(amount float64, group string) float64 { - originalAmount := amount - if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { - amount = amount / common.QuotaPerUnit - } - topupGroupRatio := common.GetTopupGroupRatio(group) - if topupGroupRatio == 0 { - topupGroupRatio = 1 - } - discount := 1.0 - if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { - if ds > 0 { - discount = ds - } - } - payMoney := amount * setting.AlipayUnitPrice * topupGroupRatio * discount - return payMoney -} - -// getAlipayMinTopup 获取支付宝最低充值数量 -func getAlipayMinTopup() int64 { - minTopup := setting.AlipayMinTopUp - if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { - minTopup = minTopup * int(common.QuotaPerUnit) - } - return int64(minTopup) -} - -// wrapAlipayPublicKey 将支付宝公钥包装为 PEM 格式 -// 输入可能是:原始 Base64 字符串 或 已有 PEM 格式 -func wrapAlipayPublicKey(pubKey string) string { - if strings.Contains(pubKey, "-----BEGIN") { - return pubKey - } - // 去除空白字符 - cleaned := strings.ReplaceAll(pubKey, "\n", "") - cleaned = strings.ReplaceAll(cleaned, "\r", "") - cleaned = strings.TrimSpace(cleaned) - // Base64 解码为 DER 字节 - derBytes, err := base64.StdEncoding.DecodeString(cleaned) - if err != nil { - // 如果解码失败,原样返回让上层报错 - return pubKey - } - return string(pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: derBytes, - })) -} diff --git a/controller/topup_wechat.go b/controller/topup_wechat.go index 47aff2c..9eae550 100644 --- a/controller/topup_wechat.go +++ b/controller/topup_wechat.go @@ -18,7 +18,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" - "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" "github.com/go-pay/gopay" @@ -355,31 +354,10 @@ func WechatPayWebhook(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) } -// getWechatPayMoney 计算微信支付应付金额(元) func getWechatPayMoney(amount float64, group string) float64 { - originalAmount := amount - if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { - amount = amount / common.QuotaPerUnit - } - topupGroupRatio := common.GetTopupGroupRatio(group) - if topupGroupRatio == 0 { - topupGroupRatio = 1 - } - discount := 1.0 - if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { - if ds > 0 { - discount = ds - } - } - payMoney := amount * setting.WechatPayUnitPrice * topupGroupRatio * discount - return payMoney + return calcPayMoney(amount, group, setting.WechatPayUnitPrice) } -// getWechatMinTopup 获取微信支付最低充值数量 func getWechatMinTopup() int64 { - minTopup := setting.WechatPayMinTopUp - if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { - minTopup = minTopup * int(common.QuotaPerUnit) - } - return int64(minTopup) + return calcMinTopup(setting.WechatPayMinTopUp) } diff --git a/model/topup_alipay.go b/model/topup_alipay.go deleted file mode 100644 index 26bee68..0000000 --- a/model/topup_alipay.go +++ /dev/null @@ -1,80 +0,0 @@ -package model - -import ( - "errors" - "fmt" - - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/logger" - - "github.com/shopspring/decimal" - "gorm.io/gorm" -) - -// RechargeAlipay 支付宝支付充值完成(由回调触发) -// 与 RechargeWechat 类似,使用事务+行锁保证幂等 -func RechargeAlipay(tradeNo string) error { - if tradeNo == "" { - return errors.New("未提供支付单号") - } - - var quotaToAdd int64 - var payMoney float64 - var userId int - - refCol := "`trade_no`" - if common.UsingPostgreSQL { - refCol = `"trade_no"` - } - - err := DB.Transaction(func(tx *gorm.DB) error { - topUp := &TopUp{} - if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { - return errors.New("充值订单不存在") - } - - if topUp.Status == common.TopUpStatusSuccess { - // 已处理,幂等返回 - return nil - } - - if topUp.Status != common.TopUpStatusPending { - return errors.New("充值订单状态错误") - } - - topUp.CompleteTime = common.GetTimestamp() - topUp.Status = common.TopUpStatusSuccess - if err := tx.Save(topUp).Error; err != nil { - return err - } - - // 支付宝充值额度计算: - // topUp.Money = req.Amount * topUpGroupRatio * unitPrice * discount(经分组倍率和折扣调整后的实际支付金额) - // 充值额度 = topUp.Money * QuotaPerUnit - dMoney := decimal.NewFromFloat(topUp.Money) - dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) - quotaToAdd = dMoney.Mul(dQuotaPerUnit).IntPart() - - if quotaToAdd <= 0 { - return errors.New("无效的充值额度") - } - - if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { - return err - } - - userId = topUp.UserId - payMoney = topUp.Money - return nil - }) - - if err != nil { - return err - } - - if quotaToAdd > 0 { - RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用支付宝充值成功,充值金额: %v,支付金额:%.2f", logger.FormatQuota(int(quotaToAdd)), payMoney)) - } - - return nil -} diff --git a/model/topup_wechat.go b/model/topup_wechat.go index 0b8772a..bc799d3 100644 --- a/model/topup_wechat.go +++ b/model/topup_wechat.go @@ -12,8 +12,18 @@ import ( ) // RechargeWechat 微信支付充值完成(由回调触发) -// 与 Recharge/RechargeCreem 类似,使用事务+行锁保证幂等 func RechargeWechat(tradeNo string) error { + return rechargeByQRCodePayment(tradeNo, "微信支付") +} + +// RechargeAlipay 支付宝充值完成(由回调触发) +func RechargeAlipay(tradeNo string) error { + return rechargeByQRCodePayment(tradeNo, "支付宝") +} + +// rechargeByQRCodePayment 扫码支付充值完成(微信/支付宝通用) +// 使用事务+行锁保证幂等 +func rechargeByQRCodePayment(tradeNo string, paymentMethod string) error { if tradeNo == "" { return errors.New("未提供支付单号") } @@ -34,7 +44,6 @@ func RechargeWechat(tradeNo string) error { } if topUp.Status == common.TopUpStatusSuccess { - // 已处理,幂等返回 return nil } @@ -48,9 +57,6 @@ func RechargeWechat(tradeNo string) error { return err } - // 微信支付充值额度计算: - // topUp.Money = req.Amount * topUpGroupRatio(经分组倍率调整后的数量) - // 充值额度 = topUp.Money * QuotaPerUnit(与 Stripe 的 Recharge 逻辑一致) dMoney := decimal.NewFromFloat(topUp.Money) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) quotaToAdd = dMoney.Mul(dQuotaPerUnit).IntPart() @@ -73,7 +79,7 @@ func RechargeWechat(tradeNo string) error { } if quotaToAdd > 0 { - RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用微信支付充值成功,充值金额: %v,支付金额:%.2f", logger.FormatQuota(int(quotaToAdd)), payMoney)) + RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用%s充值成功,充值金额: %v,支付金额:%.2f", paymentMethod, logger.FormatQuota(int(quotaToAdd)), payMoney)) } return nil diff --git a/web/src/components/topup/WechatPayQRCodeModal.jsx b/web/src/components/topup/WechatPayQRCodeModal.jsx deleted file mode 100644 index 200f63a..0000000 --- a/web/src/components/topup/WechatPayQRCodeModal.jsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { Modal, Typography, Spin, Button } from '@douyinfe/semi-ui'; -import { QRCodeSVG } from 'qrcode.react'; -import { API } from '../../helpers'; -import { useTranslation } from 'react-i18next'; - -const { Text } = Typography; - -const POLL_INTERVAL = 2000; - -export default function WechatPayQRCodeModal({ visible, qrCodeUrl, tradeNo, onClose, onSuccess }) { - const { t } = useTranslation(); - const [status, setStatus] = useState('pending'); - const [countdown, setCountdown] = useState(300); - const timerRef = useRef(null); - const pollRef = useRef(null); - - const pollStatus = useCallback(async () => { - if (!tradeNo) return; - try { - const res = await API.get(`/api/user/wechat/pay/status?trade_no=${tradeNo}`); - const { data } = res.data; - if (data?.status === 'success') { - setStatus('success'); - clearInterval(pollRef.current); - clearInterval(timerRef.current); - if (onSuccess) { - setTimeout(onSuccess, 1000); - } - } else if (data?.status === 'expired') { - setStatus('expired'); - clearInterval(pollRef.current); - clearInterval(timerRef.current); - } - } catch (e) { - // 忽略轮询错误 - } - }, [tradeNo, onSuccess]); - - useEffect(() => { - if (visible && tradeNo && status === 'pending') { - pollRef.current = setInterval(pollStatus, POLL_INTERVAL); - timerRef.current = setInterval(() => { - setCountdown((prev) => { - if (prev <= 1) { - setStatus('expired'); - clearInterval(pollRef.current); - clearInterval(timerRef.current); - return 0; - } - return prev - 1; - }); - }, 1000); - } - - return () => { - clearInterval(pollRef.current); - clearInterval(timerRef.current); - }; - }, [visible, tradeNo, status, pollStatus]); - - useEffect(() => { - if (visible) { - setStatus('pending'); - setCountdown(300); - } - }, [visible]); - - const formatCountdown = (seconds) => { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - }; - - const renderContent = () => { - if (status === 'success') { - return ( -
- - {t('支付成功!')} - -
- ); - } - - if (status === 'expired') { - return ( -
- - {t('二维码已过期,请重新发起支付')} - -
- -
-
- ); - } - - return ( -
-
- -
-
- - {t('请使用微信扫描二维码完成支付')} - -
-
- - - {t('等待支付中...')} ({formatCountdown(countdown)}) - -
-
- ); - }; - - return ( - - {renderContent()} - - ); -} diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 5b2101e..3ca0309 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -38,7 +38,6 @@ import InvitationCard from './InvitationCard'; import TransferModal from './modals/TransferModal'; import PaymentConfirmModal from './modals/PaymentConfirmModal'; import TopupHistoryModal from './modals/TopupHistoryModal'; -import WechatPayQRCodeModal from './WechatPayQRCodeModal'; import QRCodePayModal from './QRCodePayModal'; const TopUp = () => {