- 提取通用 calcPayMoney/calcMinTopup 函数,消除微信和支付宝控制器中的重复计费逻辑 - 合并 RechargeAlipay/RechargeWechat 为 rechargeByQRCodePayment 内部函数,删除 model/topup_alipay.go - 复用已有的 wrapAsPEM 替换支付宝专用的 wrapAlipayPublicKey - 删除被 QRCodePayModal 替代的 WechatPayQRCodeModal.jsx - 修复 controller/topup.go 中支付宝代码块的缩进错误 净减 264 行代码。 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -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) | |||
| @@ -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, | |||
| })) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| @@ -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 ( | |||
| <div style={{ textAlign: 'center', padding: '20px 0' }}> | |||
| <Text style={{ fontSize: 18, color: '#28a745' }}> | |||
| {t('支付成功!')} | |||
| </Text> | |||
| </div> | |||
| ); | |||
| } | |||
| if (status === 'expired') { | |||
| return ( | |||
| <div style={{ textAlign: 'center', padding: '20px 0' }}> | |||
| <Text style={{ fontSize: 16, color: '#dc3545' }}> | |||
| {t('二维码已过期,请重新发起支付')} | |||
| </Text> | |||
| <div style={{ marginTop: 16 }}> | |||
| <Button onClick={onClose}>{t('关闭')}</Button> | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| return ( | |||
| <div style={{ textAlign: 'center' }}> | |||
| <div style={{ display: 'inline-block', padding: 16, background: '#fff', borderRadius: 8 }}> | |||
| <QRCodeSVG | |||
| value={qrCodeUrl} | |||
| size={256} | |||
| level='M' | |||
| includeMargin={false} | |||
| /> | |||
| </div> | |||
| <div style={{ marginTop: 16 }}> | |||
| <Text type="secondary"> | |||
| {t('请使用微信扫描二维码完成支付')} | |||
| </Text> | |||
| </div> | |||
| <div style={{ marginTop: 8 }}> | |||
| <Spin size="small" /> | |||
| <Text type="secondary" style={{ marginLeft: 8 }}> | |||
| {t('等待支付中...')} ({formatCountdown(countdown)}) | |||
| </Text> | |||
| </div> | |||
| </div> | |||
| ); | |||
| }; | |||
| return ( | |||
| <Modal | |||
| title={t('微信支付')} | |||
| visible={visible} | |||
| onCancel={onClose} | |||
| footer={null} | |||
| width={400} | |||
| centered | |||
| > | |||
| {renderContent()} | |||
| </Modal> | |||
| ); | |||
| } | |||
| @@ -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 = () => { | |||