ソースを参照

fix(payment): 修复微信支付集成多项问题

- 修复订单号超 32 字符限制(微信支付要求),改用时间戳+随机字符格式
- 修复密钥解析:添加 wrapAsPEM 兼容 PEM/Base64/DER 多种输入格式
- 修复前端:RechargeCard 未识别 enableWechatTopUp 导致充值表单不显示
- 修复前端:支付按钮禁用逻辑未区分 epay/wechat_pay
- 移除状态轮询接口的 CriticalRateLimit 防止 429
- 过滤未启用易支付的旧支付方法避免混淆
- 修复 SiLinkedin 图标不存在导致构建失败
- 简化 webhook 冗余条件判断
- 添加密钥解析相关测试

Co-Authored-By: Claude <noreply@anthropic.com>
feat/alipay-payment
fengsilin 3週間前
コミット
cf2a0a7992
8個のファイルの変更144行の追加26行の削除
  1. +1
    -2
      controller/subscription_payment_wechat.go
  2. +7
    -2
      controller/topup.go
  3. +31
    -13
      controller/topup_wechat.go
  4. +93
    -0
      controller/topup_wechat_test.go
  5. +1
    -1
      router/api-router.go
  6. +10
    -6
      web/src/components/topup/RechargeCard.jsx
  7. +1
    -0
      web/src/components/topup/index.jsx
  8. +0
    -2
      web/src/helpers/render.jsx

+ 1
- 2
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)) // 元 -> 分



+ 7
- 2
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(),


+ 31
- 13
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": "处理失败"})


+ 93
- 0
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)
}
}

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



+ 10
- 6
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 = ({
<div className='py-8 flex justify-center'>
<Spin size='large' />
</div>
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWechatTopUp ? (
<Form
getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }}
>
<div className='space-y-6'>
{(enableOnlineTopUp || enableStripeTopUp) && (
{(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp) && (
<Row gutter={12}>
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.InputNumber
field='topUpCount'
label={t('充值数量')}
disabled={!enableOnlineTopUp && !enableStripeTopUp}
disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWechatTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
@@ -295,9 +296,12 @@ const RechargeCard = ({
{payMethods.map((payMethod) => {
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' ? (
<SiAlipay size={18} color='#1677FF' />
) : payMethod.type === 'wxpay' ? (
) : payMethod.type === 'wxpay' || payMethod.type === 'wechat_pay' ? (
<SiWechat size={18} color='#07C160' />
) : payMethod.type === 'stripe' ? (
<SiStripe size={18} color='#635BFF' />
@@ -362,7 +366,7 @@ const RechargeCard = ({
</Row>
)}

{(enableOnlineTopUp || enableStripeTopUp) && (
{(enableOnlineTopUp || enableStripeTopUp || enableWechatTopUp) && (
<Form.Slot
label={
<div className='flex items-center gap-2'>


+ 1
- 0
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}


+ 0
- 2
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,


読み込み中…
キャンセル
保存