diff --git a/internal/model/wechat_config.go b/internal/model/wechat_config.go index 88b04f8..1f374f7 100644 --- a/internal/model/wechat_config.go +++ b/internal/model/wechat_config.go @@ -4,8 +4,9 @@ import "gorm.io/gorm" // 支付渠道类型常量 const ( - ProviderTypeWechat = "wechat" // 微信直连 - ProviderTypeFuiou = "fuiou" // 富友 + ProviderTypeWechat = "wechat" // 微信直连(v3 API) + ProviderTypeWechatV2 = "wechat_v2" // 微信直连(v2 API,仅需 APIv2Key,无需证书序列号) + ProviderTypeFuiou = "fuiou" // 富友 ) // WechatConfig 微信参数配置模型 diff --git a/internal/service/client_order/payment_provider.go b/internal/service/client_order/payment_provider.go index 16b71a4..472a475 100644 --- a/internal/service/client_order/payment_provider.go +++ b/internal/service/client_order/payment_provider.go @@ -44,6 +44,20 @@ func (w *wechatPaymentProvider) CreateJSAPIPayment(ctx context.Context, orderNo, 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 富友支付适配器。 type fuiouPaymentProvider struct { client *fuiou.Client @@ -92,6 +106,12 @@ func (s *Service) newPaymentProvider(config *model.WechatConfig, appID, appType return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友客户端失败") } 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: cache := wechat.NewRedisCache(s.redis) paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, s.logger) diff --git a/pkg/wechat/config.go b/pkg/wechat/config.go index 6107395..2ebeda0 100644 --- a/pkg/wechat/config.go +++ b/pkg/wechat/config.go @@ -96,6 +96,11 @@ func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cac SerialNo: wechatConfig.WxSerialNo, Cache: cache, NotifyURL: wechatConfig.WxNotifyURL, + // 禁止 PowerWeChat SDK 在当前目录创建 wechat/ 日志文件夹 + // 容器内工作目录通常无写权限,会导致 "mkdir wechat: permission denied" + Log: payment.Log{ + Stdout: true, + }, } app, err := payment.NewPayment(userConfig) @@ -111,6 +116,22 @@ func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cac 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) { if content == "" { return "", fmt.Errorf("证书内容不能为空") diff --git a/pkg/wechat/payment_v2.go b/pkg/wechat/payment_v2.go new file mode 100644 index 0000000..9ccfa4c --- /dev/null +++ b/pkg/wechat/payment_v2.go @@ -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())))) +}