Преглед изворни кода

refactor(payment): 合并微信/支付宝支付重复代码,删除冗余文件

- 提取通用 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
fengsilin пре 3 недеља
родитељ
комит
b514a0798b
7 измењених фајлова са 66 додато и 330 уклоњено
  1. +42
    -20
      controller/topup.go
  2. +10
    -63
      controller/topup_alipay.go
  3. +2
    -24
      controller/topup_wechat.go
  4. +0
    -80
      model/topup_alipay.go
  5. +12
    -6
      model/topup_wechat.go
  6. +0
    -136
      web/src/components/topup/WechatPayQRCodeModal.jsx
  7. +0
    -1
      web/src/components/topup/index.jsx

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


+ 10
- 63
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,
}))
}

+ 2
- 24
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)
}

+ 0
- 80
model/topup_alipay.go Прегледај датотеку

@@ -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
- 6
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


+ 0
- 136
web/src/components/topup/WechatPayQRCodeModal.jsx Прегледај датотеку

@@ -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>
);
}

+ 0
- 1
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 = () => {


Loading…
Откажи
Сачувај