fix: 修复微信支付容器内 mkdir 权限错误,新增 wechat_v2 支付渠道
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s
- NewPaymentAppFromConfig 加 Log.Stdout=true,禁止 PowerWeChat SDK 在容器 工作目录创建 wechat/ 日志文件夹(Permission denied 根因) - 新增 ProviderTypeWechatV2 = wechat_v2 常量,与 wechat(v3) 独立区分 - 新增 PaymentV2Service:直连统一下单 v2 接口,XML + MD5 签名 - newPaymentProvider 按 ProviderType 分支路由到对应版本
This commit is contained in:
@@ -4,8 +4,9 @@ import "gorm.io/gorm"
|
|||||||
|
|
||||||
// 支付渠道类型常量
|
// 支付渠道类型常量
|
||||||
const (
|
const (
|
||||||
ProviderTypeWechat = "wechat" // 微信直连
|
ProviderTypeWechat = "wechat" // 微信直连(v3 API)
|
||||||
ProviderTypeFuiou = "fuiou" // 富友
|
ProviderTypeWechatV2 = "wechat_v2" // 微信直连(v2 API,仅需 APIv2Key,无需证书序列号)
|
||||||
|
ProviderTypeFuiou = "fuiou" // 富友
|
||||||
)
|
)
|
||||||
|
|
||||||
// WechatConfig 微信参数配置模型
|
// WechatConfig 微信参数配置模型
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ func (w *wechatPaymentProvider) CreateJSAPIPayment(ctx context.Context, orderNo,
|
|||||||
return extractPaymentResult(result.PayConfig), nil
|
return extractPaymentResult(result.PayConfig), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wechatV2PaymentProvider 微信支付 v2 适配器。
|
||||||
|
type wechatV2PaymentProvider struct {
|
||||||
|
paymentService *wechat.PaymentV2Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJSAPIPayment 创建微信 v2 JSAPI 预下单。
|
||||||
|
func (w *wechatV2PaymentProvider) CreateJSAPIPayment(ctx context.Context, orderNo, description, openID string, amount int) (*PaymentResult, error) {
|
||||||
|
result, err := w.paymentService.CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return extractPaymentResult(result.PayConfig), nil
|
||||||
|
}
|
||||||
|
|
||||||
// fuiouPaymentProvider 富友支付适配器。
|
// fuiouPaymentProvider 富友支付适配器。
|
||||||
type fuiouPaymentProvider struct {
|
type fuiouPaymentProvider struct {
|
||||||
client *fuiou.Client
|
client *fuiou.Client
|
||||||
@@ -92,6 +106,12 @@ func (s *Service) newPaymentProvider(config *model.WechatConfig, appID, appType
|
|||||||
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友客户端失败")
|
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友客户端失败")
|
||||||
}
|
}
|
||||||
return &fuiouPaymentProvider{client: client, appID: appID, tradeType: tradeType}, nil
|
return &fuiouPaymentProvider{client: client, appID: appID, tradeType: tradeType}, nil
|
||||||
|
case model.ProviderTypeWechatV2:
|
||||||
|
svc, err := wechat.NewPaymentV2ServiceFromConfig(config, appID, s.logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付 v2 应用失败")
|
||||||
|
}
|
||||||
|
return &wechatV2PaymentProvider{paymentService: svc}, nil
|
||||||
default:
|
default:
|
||||||
cache := wechat.NewRedisCache(s.redis)
|
cache := wechat.NewRedisCache(s.redis)
|
||||||
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, s.logger)
|
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, s.logger)
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cac
|
|||||||
SerialNo: wechatConfig.WxSerialNo,
|
SerialNo: wechatConfig.WxSerialNo,
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
NotifyURL: wechatConfig.WxNotifyURL,
|
NotifyURL: wechatConfig.WxNotifyURL,
|
||||||
|
// 禁止 PowerWeChat SDK 在当前目录创建 wechat/ 日志文件夹
|
||||||
|
// 容器内工作目录通常无写权限,会导致 "mkdir wechat: permission denied"
|
||||||
|
Log: payment.Log{
|
||||||
|
Stdout: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := payment.NewPayment(userConfig)
|
app, err := payment.NewPayment(userConfig)
|
||||||
@@ -111,6 +116,22 @@ func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cac
|
|||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewPaymentV2ServiceFromConfig 从数据库配置创建微信支付 v2 服务实例
|
||||||
|
// v2 仅需 APIv2Key,不要求证书序列号和 v3 密钥
|
||||||
|
func NewPaymentV2ServiceFromConfig(wechatConfig *model.WechatConfig, appID string, logger *zap.Logger) (*PaymentV2Service, error) {
|
||||||
|
if wechatConfig == nil {
|
||||||
|
return nil, fmt.Errorf("微信配置不能为空")
|
||||||
|
}
|
||||||
|
if appID == "" {
|
||||||
|
return nil, fmt.Errorf("appID 不能为空")
|
||||||
|
}
|
||||||
|
if wechatConfig.WxMchID == "" || wechatConfig.WxAPIV2Key == "" {
|
||||||
|
return nil, fmt.Errorf("微信支付 v2 配置不完整:缺少 wx_mch_id 或 wx_api_v2_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewPaymentV2Service(appID, wechatConfig.WxMchID, wechatConfig.WxAPIV2Key, wechatConfig.WxNotifyURL, logger), nil
|
||||||
|
}
|
||||||
|
|
||||||
func writeWechatPemTempFile(pattern, content string) (string, error) {
|
func writeWechatPemTempFile(pattern, content string) (string, error) {
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return "", fmt.Errorf("证书内容不能为空")
|
return "", fmt.Errorf("证书内容不能为空")
|
||||||
|
|||||||
203
pkg/wechat/payment_v2.go
Normal file
203
pkg/wechat/payment_v2.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package wechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wechatPayV2UnifiedOrderURL = "https://api.mch.weixin.qq.com/pay/unifiedorder"
|
||||||
|
|
||||||
|
// PaymentV2Service 微信支付 v2 服务
|
||||||
|
// 适用于仅配置 APIv2Key 的商户(无需 v3 证书序列号)
|
||||||
|
type PaymentV2Service struct {
|
||||||
|
appID string
|
||||||
|
mchID string
|
||||||
|
apiKey string
|
||||||
|
notifyURL string
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPaymentV2Service 创建微信支付 v2 服务
|
||||||
|
func NewPaymentV2Service(appID, mchID, apiKey, notifyURL string, logger *zap.Logger) *PaymentV2Service {
|
||||||
|
return &PaymentV2Service{
|
||||||
|
appID: appID,
|
||||||
|
mchID: mchID,
|
||||||
|
apiKey: apiKey,
|
||||||
|
notifyURL: notifyURL,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2UnifiedOrderRequest 统一下单请求(v2 XML 格式)
|
||||||
|
type v2UnifiedOrderRequest struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
AppID string `xml:"appid"`
|
||||||
|
MchID string `xml:"mch_id"`
|
||||||
|
NonceStr string `xml:"nonce_str"`
|
||||||
|
Sign string `xml:"sign"`
|
||||||
|
Body string `xml:"body"`
|
||||||
|
OutTradeNo string `xml:"out_trade_no"`
|
||||||
|
TotalFee int `xml:"total_fee"`
|
||||||
|
SpbillIP string `xml:"spbill_create_ip"`
|
||||||
|
NotifyURL string `xml:"notify_url"`
|
||||||
|
TradeType string `xml:"trade_type"`
|
||||||
|
OpenID string `xml:"openid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2UnifiedOrderResponse 统一下单响应(v2 XML 格式)
|
||||||
|
type v2UnifiedOrderResponse struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ReturnCode string `xml:"return_code"`
|
||||||
|
ReturnMsg string `xml:"return_msg"`
|
||||||
|
ResultCode string `xml:"result_code"`
|
||||||
|
ErrCode string `xml:"err_code"`
|
||||||
|
ErrCodeDes string `xml:"err_code_des"`
|
||||||
|
PrepayID string `xml:"prepay_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJSAPIOrder 创建 v2 JSAPI 支付订单
|
||||||
|
func (s *PaymentV2Service) CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) {
|
||||||
|
if orderNo == "" || openID == "" || amount <= 0 {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "订单号、OpenID 和金额不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceStr := v2GenerateNonceStr()
|
||||||
|
|
||||||
|
params := map[string]string{
|
||||||
|
"appid": s.appID,
|
||||||
|
"mch_id": s.mchID,
|
||||||
|
"nonce_str": nonceStr,
|
||||||
|
"body": description,
|
||||||
|
"out_trade_no": orderNo,
|
||||||
|
"total_fee": fmt.Sprintf("%d", amount),
|
||||||
|
"spbill_create_ip": "127.0.0.1",
|
||||||
|
"notify_url": s.notifyURL,
|
||||||
|
"trade_type": "JSAPI",
|
||||||
|
"openid": openID,
|
||||||
|
}
|
||||||
|
params["sign"] = v2SignMD5(params, s.apiKey)
|
||||||
|
|
||||||
|
reqBody := &v2UnifiedOrderRequest{
|
||||||
|
AppID: params["appid"],
|
||||||
|
MchID: params["mch_id"],
|
||||||
|
NonceStr: params["nonce_str"],
|
||||||
|
Sign: params["sign"],
|
||||||
|
Body: params["body"],
|
||||||
|
OutTradeNo: params["out_trade_no"],
|
||||||
|
TotalFee: amount,
|
||||||
|
SpbillIP: params["spbill_create_ip"],
|
||||||
|
NotifyURL: params["notify_url"],
|
||||||
|
TradeType: params["trade_type"],
|
||||||
|
OpenID: params["openid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlBytes, err := xml.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "序列化统一下单请求失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, wechatPayV2UnifiedOrderURL, strings.NewReader(string(xmlBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "构建统一下单请求失败")
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/xml")
|
||||||
|
|
||||||
|
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("调用微信 v2 统一下单接口失败", zap.String("order_no", orderNo), zap.Error(err))
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "调用统一下单接口失败")
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "读取统一下单响应失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp v2UnifiedOrderResponse
|
||||||
|
if err = xml.Unmarshal(respBytes, &resp); err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "解析统一下单响应失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ReturnCode != "SUCCESS" {
|
||||||
|
s.logger.Error("微信 v2 统一下单通信失败",
|
||||||
|
zap.String("order_no", orderNo),
|
||||||
|
zap.String("return_msg", resp.ReturnMsg),
|
||||||
|
)
|
||||||
|
return nil, errors.New(errors.CodeWechatPayFailed, "统一下单通信失败: "+resp.ReturnMsg)
|
||||||
|
}
|
||||||
|
if resp.ResultCode != "SUCCESS" {
|
||||||
|
s.logger.Error("微信 v2 统一下单业务失败",
|
||||||
|
zap.String("order_no", orderNo),
|
||||||
|
zap.String("err_code", resp.ErrCode),
|
||||||
|
zap.String("err_code_des", resp.ErrCodeDes),
|
||||||
|
)
|
||||||
|
return nil, errors.New(errors.CodeWechatPayFailed, "统一下单业务失败: "+resp.ErrCodeDes)
|
||||||
|
}
|
||||||
|
|
||||||
|
payConfig := s.buildJSAPIPayConfig(resp.PrepayID, nonceStr)
|
||||||
|
|
||||||
|
s.logger.Info("创建 v2 JSAPI 支付订单成功",
|
||||||
|
zap.String("order_no", orderNo),
|
||||||
|
zap.String("prepay_id", resp.PrepayID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &JSAPIPayResult{
|
||||||
|
PrepayID: resp.PrepayID,
|
||||||
|
PayConfig: payConfig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildJSAPIPayConfig 构建 JSAPI 唤起支付所需的签名参数
|
||||||
|
func (s *PaymentV2Service) buildJSAPIPayConfig(prepayID, nonceStr string) map[string]string {
|
||||||
|
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
params := map[string]string{
|
||||||
|
"appId": s.appID,
|
||||||
|
"timeStamp": timestamp,
|
||||||
|
"nonceStr": nonceStr,
|
||||||
|
"package": "prepay_id=" + prepayID,
|
||||||
|
"signType": "MD5",
|
||||||
|
}
|
||||||
|
params["paySign"] = v2SignMD5(params, s.apiKey)
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2SignMD5 对参数按字典序排序后拼接 key,计算 MD5 大写签名
|
||||||
|
func v2SignMD5(params map[string]string, apiKey string) string {
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for k := range params {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, k := range keys {
|
||||||
|
v := params[k]
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(k)
|
||||||
|
sb.WriteString("=")
|
||||||
|
sb.WriteString(v)
|
||||||
|
sb.WriteString("&")
|
||||||
|
}
|
||||||
|
sb.WriteString("key=")
|
||||||
|
sb.WriteString(apiKey)
|
||||||
|
|
||||||
|
hash := md5.Sum([]byte(sb.String()))
|
||||||
|
return strings.ToUpper(fmt.Sprintf("%x", hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
func v2GenerateNonceStr() string {
|
||||||
|
return fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user