From cf2a0a7992e0c41272048bbbd4970b2f68bfa000 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Mon, 13 Apr 2026 16:34:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(payment):=20=E4=BF=AE=E5=A4=8D=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E9=9B=86=E6=88=90=E5=A4=9A=E9=A1=B9?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复订单号超 32 字符限制(微信支付要求),改用时间戳+随机字符格式 - 修复密钥解析:添加 wrapAsPEM 兼容 PEM/Base64/DER 多种输入格式 - 修复前端:RechargeCard 未识别 enableWechatTopUp 导致充值表单不显示 - 修复前端:支付按钮禁用逻辑未区分 epay/wechat_pay - 移除状态轮询接口的 CriticalRateLimit 防止 429 - 过滤未启用易支付的旧支付方法避免混淆 - 修复 SiLinkedin 图标不存在导致构建失败 - 简化 webhook 冗余条件判断 - 添加密钥解析相关测试 Co-Authored-By: Claude --- controller/subscription_payment_wechat.go | 3 +- controller/topup.go | 9 ++- controller/topup_wechat.go | 44 +++++++---- controller/topup_wechat_test.go | 93 +++++++++++++++++++++++ router/api-router.go | 2 +- web/src/components/topup/RechargeCard.jsx | 16 ++-- web/src/components/topup/index.jsx | 1 + web/src/helpers/render.jsx | 2 - 8 files changed, 144 insertions(+), 26 deletions(-) diff --git a/controller/subscription_payment_wechat.go b/controller/subscription_payment_wechat.go index 9a5e374..cd5aec5 100644 --- a/controller/subscription_payment_wechat.go +++ b/controller/subscription_payment_wechat.go @@ -73,8 +73,7 @@ func SubscriptionRequestWechatPay(c *gin.Context) { return } - reference := fmt.Sprintf("wx-sub-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) - tradeNo := "wx_sub_" + common.Sha1([]byte(reference)) + tradeNo := fmt.Sprintf("ws%d%s", time.Now().UnixMilli(), randstr.String(8)) totalFee := int(math.Round(plan.PriceAmount * 100)) // 元 -> 分 diff --git a/controller/topup.go b/controller/topup.go index 30b6e07..e2e83b4 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -23,8 +23,13 @@ import ( ) func GetTopUpInfo(c *gin.Context) { - // 获取支付方式 + enableOnlineTopup := operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "" + + // 获取支付方式:易支付未启用时过滤掉旧支付方法 payMethods := operation_setting.PayMethods + if !enableOnlineTopup { + payMethods = []map[string]string{} + } // 如果启用了 Stripe 支付,添加到支付方法列表 if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" { @@ -69,7 +74,7 @@ func GetTopUpInfo(c *gin.Context) { } data := gin.H{ - "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", + "enable_online_topup": enableOnlineTopup, "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", "enable_wechat_topup": setting.IsWechatPayConfigured(), diff --git a/controller/topup_wechat.go b/controller/topup_wechat.go index 3ba2683..47aff2c 100644 --- a/controller/topup_wechat.go +++ b/controller/topup_wechat.go @@ -3,6 +3,7 @@ package controller import ( "context" "encoding/base64" + "encoding/pem" "errors" "fmt" "log" @@ -10,6 +11,7 @@ import ( "net/http" "os" "strconv" + "strings" "sync" "time" @@ -61,7 +63,7 @@ func getWechatPayClient() (*wechat.ClientV3, error) { if err != nil { return nil, fmt.Errorf("解析商户私钥 Base64 失败: %w", err) } - privateKey = string(keyBytes) + privateKey = wrapAsPEM(keyBytes, "PRIVATE KEY") } else { keyBytes, err := os.ReadFile(setting.WechatPayKeyPath) if err != nil { @@ -81,7 +83,7 @@ func getWechatPayClient() (*wechat.ClientV3, error) { if err != nil { return nil, fmt.Errorf("解析微信公钥 Base64 失败: %w", err) } - if err := client.AutoVerifySignByPublicKey(pubKeyBytes, setting.WechatPayPubKeyID); err != nil { + if err := client.AutoVerifySignByPublicKey([]byte(wrapAsPEM(pubKeyBytes, "PUBLIC KEY")), setting.WechatPayPubKeyID); err != nil { return nil, fmt.Errorf("开启公钥验签失败: %w", err) } } else { @@ -89,7 +91,7 @@ func getWechatPayClient() (*wechat.ClientV3, error) { if err != nil { return nil, fmt.Errorf("读取微信公钥文件失败: %w", err) } - if err := client.AutoVerifySignByPublicKey(pubKeyBytes, setting.WechatPayPubKeyID); err != nil { + if err := client.AutoVerifySignByPublicKey([]byte(wrapAsPEM(pubKeyBytes, "PUBLIC KEY")), setting.WechatPayPubKeyID); err != nil { return nil, fmt.Errorf("开启公钥验签失败: %w", err) } } @@ -98,6 +100,29 @@ func getWechatPayClient() (*wechat.ClientV3, error) { return wechatPayClient, nil } +// wrapAsPEM 确保 keyBytes 是 PEM 格式,兼容多种输入: +// 1. 已是 PEM(含 -----BEGIN)→ 原样返回 +// 2. 是 DER base64 文本(如 PEM 内层内容)→ 解码为 DER 后包装为 PEM +// 3. 是原始 DER 字节 → 直接包装为 PEM +func wrapAsPEM(keyBytes []byte, keyType string) string { + content := string(keyBytes) + if strings.Contains(content, "-----BEGIN") { + return content + } + // 尝试 base64 解码:内容可能是 PEM 文件内层的 base64 文本 + if derBytes, err := base64.StdEncoding.DecodeString(content); err == nil && len(derBytes) > 0 { + return string(pem.EncodeToMemory(&pem.Block{ + Type: keyType, + Bytes: derBytes, + })) + } + // 当作原始 DER 字节 + return string(pem.EncodeToMemory(&pem.Block{ + Type: keyType, + Bytes: keyBytes, + })) +} + // WechatPayRequest 微信支付请求参数 type WechatPayRequest struct { Amount int64 `json:"amount"` @@ -205,8 +230,7 @@ func RequestWechatPay(c *gin.Context) { } chargedMoney := float64(req.Amount) * topupGroupRatio - reference := fmt.Sprintf("wx-pay-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(4)) - tradeNo := "wx_" + common.Sha1([]byte(reference)) + tradeNo := fmt.Sprintf("wx%d%s", time.Now().UnixMilli(), randstr.String(8)) payMoney := getWechatPayMoney(float64(req.Amount), group) totalFee := int(math.Round(payMoney * 100)) // 元 -> 分 @@ -235,7 +259,7 @@ func RequestWechatPay(c *gin.Context) { c.JSON(200, gin.H{ "message": "success", "data": gin.H{ - "trade_no": tradeNo, + "trade_no": tradeNo, "qr_code_url": codeUrl, }, }) @@ -273,7 +297,6 @@ func WechatPayStatus(c *gin.Context) { // WechatPayWebhook 处理微信支付回调通知 func WechatPayWebhook(c *gin.Context) { - // 解析 V3 回调请求 notifyReq, err := wechat.V3ParseNotify(c.Request) if err != nil { log.Printf("解析微信支付回调失败: %v", err) @@ -288,14 +311,12 @@ func WechatPayWebhook(c *gin.Context) { return } - // 验证签名 if err := notifyReq.VerifySignByPK(client.WxPublicKey()); err != nil { log.Printf("微信支付回调验签失败: %v", err) c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "验签失败"}) return } - // 解密回调数据 result, err := notifyReq.DecryptPayCipherText(setting.WechatPayAPIv3Key) if err != nil { log.Printf("解密微信支付回调数据失败: %v", err) @@ -303,7 +324,6 @@ func WechatPayWebhook(c *gin.Context) { return } - // 只处理支付成功 if result.TradeState != "SUCCESS" { log.Printf("微信支付回调非成功状态: %s, 订单号: %s", result.TradeState, result.OutTradeNo) c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) @@ -312,7 +332,6 @@ func WechatPayWebhook(c *gin.Context) { tradeNo := result.OutTradeNo - // 先尝试完成订阅订单 LockOrder(tradeNo) defer UnlockOrder(tradeNo) @@ -320,13 +339,12 @@ func WechatPayWebhook(c *gin.Context) { log.Printf("微信支付订阅订单完成: %s", tradeNo) c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) return - } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { + } else if !errors.Is(err, model.ErrSubscriptionOrderNotFound) { log.Printf("微信支付订阅订单处理失败: %s, err: %s", tradeNo, err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "处理失败"}) return } - // 处理充值订单 if err := model.RechargeWechat(tradeNo); err != nil { log.Printf("微信支付充值失败: %s, err: %s", tradeNo, err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "处理失败"}) diff --git a/controller/topup_wechat_test.go b/controller/topup_wechat_test.go index af48e42..f4067e6 100644 --- a/controller/topup_wechat_test.go +++ b/controller/topup_wechat_test.go @@ -2,9 +2,12 @@ package controller import ( "bytes" + "encoding/base64" "encoding/json" + "encoding/pem" "net/http" "net/http/httptest" + "os" "testing" "github.com/QuantumNous/new-api/common" @@ -12,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/setting" "github.com/glebarez/sqlite" "github.com/gin-gonic/gin" + "github.com/go-pay/gopay/wechat/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -372,3 +376,92 @@ func TestResetWechatPayClient_CallbackRegistered(t *testing.T) { func TestPaymentMethodWechatPay(t *testing.T) { assert.Equal(t, "wechat_pay", PaymentMethodWechatPay) } + +// ========== Base64 密钥解析测试 ========== + +func TestBase64PemDecoding(t *testing.T) { + // 测试:PEM 文件整体 Base64 编码 → 解码后应能得到有效 PEM + keyPath := "C:/Users/28221/Downloads/WXCertUtil/cert/1602799864_20260402_cert/apiclient_key.pem" + pubKeyPath := "C:/Users/28221/Downloads/pub_key.pem" + + // 读取原始文件 + origKeyBytes, err := os.ReadFile(keyPath) + require.NoError(t, err, "读取私钥文件失败") + t.Logf("原始私钥前30字符: %s", string(origKeyBytes[:30])) + + // 模拟 getWechatPayClient 的逻辑:base64 编码 → 存入配置 → 解码 + encoded := base64.StdEncoding.EncodeToString(origKeyBytes) + t.Logf("Base64 编码后长度: %d", len(encoded)) + + // 解码 + decoded, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err, "Base64 解码失败") + assert.Equal(t, string(origKeyBytes), string(decoded), "解码后应与原始一致") + + // 验证解码后确实是 PEM 格式 + assert.Contains(t, string(decoded), "-----BEGIN", "解码后应包含 PEM 头") + t.Logf("解码后前30字符: %s", string(decoded[:30])) + + // 测试 gopay 能否接受这个格式 + mchID := "1602799864" + serialNo := "1ABE61AF2D4FA785F2782320B001D8B2E5517840" + apiV3Key := "Pr79nX6Ej4Xw4eReJ7KM7NhE4Hezec3y" + + client, err := wechat.NewClientV3(mchID, serialNo, apiV3Key, string(decoded)) + if err != nil { + t.Errorf("NewClientV3 失败: %v", err) + } else { + t.Log("NewClientV3 成功") + _ = client + } + + // 读取公钥 + pubKeyBytes, err := os.ReadFile(pubKeyPath) + require.NoError(t, err, "读取公钥文件失败") + t.Logf("原始公钥前30字符: %s", string(pubKeyBytes[:30])) + + // Base64 编码公钥 + encodedPub := base64.StdEncoding.EncodeToString(pubKeyBytes) + decodedPub, err := base64.StdEncoding.DecodeString(encodedPub) + require.NoError(t, err) + assert.Contains(t, string(decodedPub), "-----BEGIN", "解码后应包含 PEM 头") +} + +func TestWrapAsPEM(t *testing.T) { + // Case 1: PEM 格式输入应原样返回 + pemInput := "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkq\n-----END PRIVATE KEY-----" + result := wrapAsPEM([]byte(pemInput), "PRIVATE KEY") + assert.Contains(t, result, "-----BEGIN PRIVATE KEY-----") + + // Case 2: 裸 DER 字节应自动包装 + rawDER := []byte{0x30, 0x82, 0x02, 0x5c} + result = wrapAsPEM(rawDER, "PRIVATE KEY") + assert.Contains(t, result, "-----BEGIN PRIVATE KEY-----") + assert.Contains(t, result, "-----END PRIVATE KEY-----") + + // Case 3: PEM 内层 base64 文本(双重编码场景)应正确解码为 DER 再包装 + // 模拟:原始 DER → base64 得到 "MIIEvAI..." 文本 → 作为输入 + innerB64 := base64.StdEncoding.EncodeToString(rawDER) + result = wrapAsPEM([]byte(innerB64), "PRIVATE KEY") + assert.Contains(t, result, "-----BEGIN PRIVATE KEY-----") + // 验证内层内容解码后等于原始 DER + block, _ := pem.Decode([]byte(result)) + assert.Equal(t, rawDER, block.Bytes) + + // Case 4: 用实际私钥文件测试内层 base64 场景 + keyPath := "C:/Users/28221/Downloads/WXCertUtil/cert/1602799864_20260402_cert/apiclient_key.pem" + if keyBytes, err := os.ReadFile(keyPath); err == nil { + // 提取 PEM 内层的 base64 文本 + block, _ := pem.Decode(keyBytes) + require.NotNil(t, block, "原始 PEM 解析失败") + innerB64 := base64.StdEncoding.EncodeToString(block.Bytes) + + // 用内层 base64 文本作为输入 + result := wrapAsPEM([]byte(innerB64), "PRIVATE KEY") + assert.Contains(t, result, "-----BEGIN PRIVATE KEY-----") + + // 验证 gopay 能接受 + _, err := wechat.NewClientV3("test_mch", "test_serial", "test_key", result) + t.Logf("gopay NewClientV3 with inner-b64 input: err=%v", err) + } +} diff --git a/router/api-router.go b/router/api-router.go index 9fe742d..7008c55 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -92,7 +92,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/wechat/pay/amount", controller.RequestWechatPayAmount) selfRoute.POST("/wechat/pay", middleware.CriticalRateLimit(), controller.RequestWechatPay) - selfRoute.GET("/wechat/pay/status", middleware.CriticalRateLimit(), controller.WechatPayStatus) + selfRoute.GET("/wechat/pay/status", controller.WechatPayStatus) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 1ce0230..a9907fe 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -57,6 +57,7 @@ const RechargeCard = ({ enableOnlineTopUp, enableStripeTopUp, enableCreemTopUp, + enableWechatTopUp, creemProducts, creemPreTopUp, presetAmounts, @@ -224,19 +225,19 @@ const RechargeCard = ({
- ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? ( + ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWechatTopUp ? (
(onlineFormApiRef.current = api)} initValues={{ topUpCount: topUpCount }} >
- {(enableOnlineTopUp || enableStripeTopUp) && ( + {(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp) && ( { const minTopupVal = Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; + const isWechatPay = payMethod.type === 'wechat_pay'; + const isEpay = !isStripe && !isWechatPay; const disabled = - (!enableOnlineTopUp && !isStripe) || + (!enableOnlineTopUp && isEpay) || (!enableStripeTopUp && isStripe) || + (!enableWechatTopUp && isWechatPay) || minTopupVal > Number(topUpCount || 0); const buttonEl = ( @@ -313,7 +317,7 @@ const RechargeCard = ({ icon={ payMethod.type === 'alipay' ? ( - ) : payMethod.type === 'wxpay' ? ( + ) : payMethod.type === 'wxpay' || payMethod.type === 'wechat_pay' ? ( ) : payMethod.type === 'stripe' ? ( @@ -362,7 +366,7 @@ const RechargeCard = ({ )} - {(enableOnlineTopUp || enableStripeTopUp) && ( + {(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp) && ( diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 949714b..cd64578 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -792,6 +792,7 @@ const TopUp = () => { enableOnlineTopUp={enableOnlineTopUp} enableStripeTopUp={enableStripeTopUp} enableCreemTopUp={enableCreemTopUp} + enableWechatTopUp={enableWechatTopUp} creemProducts={creemProducts} creemPreTopUp={creemPreTopUp} presetAmounts={presetAmounts} diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 3ba198c..13f7b63 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -89,7 +89,6 @@ import { SiGitlab, SiGoogle, SiKeycloak, - SiLinkedin, SiNextcloud, SiNotion, SiOkta, @@ -504,7 +503,6 @@ const oauthProviderIconMap = { google: SiGoogle, discord: SiDiscord, facebook: SiFacebook, - linkedin: SiLinkedin, x: SiX, twitter: SiX, slack: SiSlack,