- 修复订单号超 32 字符限制(微信支付要求),改用时间戳+随机字符格式 - 修复密钥解析:添加 wrapAsPEM 兼容 PEM/Base64/DER 多种输入格式 - 修复前端:RechargeCard 未识别 enableWechatTopUp 导致充值表单不显示 - 修复前端:支付按钮禁用逻辑未区分 epay/wechat_pay - 移除状态轮询接口的 CriticalRateLimit 防止 429 - 过滤未启用易支付的旧支付方法避免混淆 - 修复 SiLinkedin 图标不存在导致构建失败 - 简化 webhook 冗余条件判断 - 添加密钥解析相关测试 Co-Authored-By: Claude <noreply@anthropic.com>feat/alipay-payment
| @@ -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)) // 元 -> 分 | |||
| @@ -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(), | |||
| @@ -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": "处理失败"}) | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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) | |||
| @@ -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'> | |||
| @@ -792,6 +792,7 @@ const TopUp = () => { | |||
| enableOnlineTopUp={enableOnlineTopUp} | |||
| enableStripeTopUp={enableStripeTopUp} | |||
| enableCreemTopUp={enableCreemTopUp} | |||
| enableWechatTopUp={enableWechatTopUp} | |||
| creemProducts={creemProducts} | |||
| creemPreTopUp={creemPreTopUp} | |||
| presetAmounts={presetAmounts} | |||
| @@ -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, | |||