Files
junhong_cmp_fiber/pkg/wechat/payment_v2.go
break 8fc667daee
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
让代理充值复用现有网页支付能力
微信按当前 v2/v3 配置分别生成 MWEB/H5 链接,支付宝复用 C 端 WAP 链接,并让可用支付方式基于生效配置判断。

Constraint: 支付链接统一通过 qr_content 返回,由前端渲染二维码;按要求不运行测试

Rejected: 微信 Native 与支付宝当面付 | 会引入非当前商户配置所需的额外产品开通

Confidence: high

Scope-risk: moderate

Directive: 微信 H5/MWEB 二维码仅承诺系统相机或外部浏览器扫码链路

Tested: 相关 Go 包编译通过;gofmt 与 git diff --check 通过

Not-tested: 按用户要求未运行自动化测试及真实支付联调
2026-07-30 11:41:50 +08:00

519 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package wechat
import (
"bytes"
"context"
"crypto/md5"
"crypto/subtle"
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/bytedance/sonic"
"go.uber.org/zap"
)
const (
wechatPayV2UnifiedOrderURL = "https://api.mch.weixin.qq.com/pay/unifiedorder"
wechatPayV2OrderQueryURL = "https://api.mch.weixin.qq.com/pay/orderquery"
)
// 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,omitempty"`
SceneInfo string `xml:"scene_info,omitempty"`
}
// 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"`
MwebURL string `xml:"mweb_url"`
}
// v2OrderQueryRequest 商户订单号查单请求v2 XML 格式)
type v2OrderQueryRequest struct {
XMLName xml.Name `xml:"xml"`
AppID string `xml:"appid"`
MchID string `xml:"mch_id"`
OutTradeNo string `xml:"out_trade_no"`
NonceStr string `xml:"nonce_str"`
Sign string `xml:"sign"`
}
// v2OrderQueryResponse 商户订单号查单响应v2 XML 格式)
type v2OrderQueryResponse 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"`
OpenID string `xml:"openid"`
TradeType string `xml:"trade_type"`
TradeState string `xml:"trade_state"`
BankType string `xml:"bank_type"`
TotalFee int64 `xml:"total_fee"`
CashFee int64 `xml:"cash_fee"`
FeeType string `xml:"fee_type"`
TransactionID string `xml:"transaction_id"`
OutTradeNo string `xml:"out_trade_no"`
Attach string `xml:"attach"`
TimeEnd string `xml:"time_end"`
TradeStateDesc string `xml:"trade_state_desc"`
}
// v2H5SceneInfo v2 MWEB 支付场景信息
type v2H5SceneInfo struct {
H5Info v2H5Info `json:"h5_info"`
}
// v2H5Info v2 H5 场景明细
type v2H5Info struct {
Type string `json:"type"`
WapURL string `json:"wap_url"`
WapName string `json:"wap_name"`
}
// 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
}
// CreateH5Order 创建 v2 MWEB 支付订单
func (s *PaymentV2Service) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *H5SceneInfo) (*H5PayResult, error) {
if orderNo == "" || description == "" || amount <= 0 || sceneInfo == nil || net.ParseIP(strings.TrimSpace(sceneInfo.PayerClientIP)) == nil {
return nil, errors.New(errors.CodeInvalidParam, "订单号、订单描述、金额和客户端 IP 不能为空")
}
websiteURL, err := v2WebsiteURL(s.notifyURL)
if err != nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "微信 H5 支付站点配置不可用")
}
sceneJSON, err := sonic.MarshalString(v2H5SceneInfo{H5Info: v2H5Info{
Type: "Wap", WapURL: websiteURL, WapName: description,
}})
if err != nil {
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "构建 H5 支付场景失败")
}
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": strings.TrimSpace(sceneInfo.PayerClientIP), "notify_url": s.notifyURL,
"trade_type": "MWEB", "scene_info": sceneJSON,
}
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"], SceneInfo: params["scene_info"],
}
respBytes, err := s.postV2XML(ctx, wechatPayV2UnifiedOrderURL, reqBody)
if err != nil {
s.logger.Error("调用微信 v2 MWEB 统一下单接口失败", zap.String("order_no", orderNo), zap.Error(err))
return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败")
}
var resp v2UnifiedOrderResponse
if err = xml.Unmarshal(respBytes, &resp); err != nil {
s.logger.Error("解析微信 v2 MWEB 统一下单响应失败", zap.String("order_no", orderNo), zap.Error(err))
return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败")
}
if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" || resp.MwebURL == "" {
s.logger.Error("微信 v2 MWEB 统一下单失败", zap.String("order_no", orderNo), zap.String("return_code", resp.ReturnCode),
zap.String("return_msg", resp.ReturnMsg), zap.String("result_code", resp.ResultCode), zap.String("err_code", resp.ErrCode), zap.String("err_code_des", resp.ErrCodeDes))
return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败")
}
if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil {
s.logger.Error("微信 v2 MWEB 统一下单响应验签失败", zap.String("order_no", orderNo), zap.Error(err))
return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败")
}
s.logger.Info("创建 v2 MWEB 支付订单成功", zap.String("order_no", orderNo))
return &H5PayResult{H5URL: resp.MwebURL}, nil
}
// QueryOrder 按商户订单号查询 v2 支付订单
func (s *PaymentV2Service) QueryOrder(ctx context.Context, orderNo string) (*OrderInfo, error) {
if orderNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
nonceStr := v2GenerateNonceStr()
params := map[string]string{
"appid": s.appID, "mch_id": s.mchID, "out_trade_no": orderNo, "nonce_str": nonceStr,
}
params["sign"] = v2SignMD5(params, s.apiKey)
reqBody := &v2OrderQueryRequest{
AppID: params["appid"], MchID: params["mch_id"], OutTradeNo: params["out_trade_no"], NonceStr: params["nonce_str"], Sign: params["sign"],
}
respBytes, err := s.postV2XML(ctx, wechatPayV2OrderQueryURL, reqBody)
if err != nil {
s.logger.Error("调用微信 v2 查单接口失败", zap.String("order_no", orderNo), zap.Error(err))
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败")
}
var resp v2OrderQueryResponse
if err = xml.Unmarshal(respBytes, &resp); err != nil {
s.logger.Error("解析微信 v2 查单响应失败", zap.String("order_no", orderNo), zap.Error(err))
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败")
}
if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" {
s.logger.Error("微信 v2 查单失败", zap.String("order_no", orderNo), zap.String("return_code", resp.ReturnCode),
zap.String("return_msg", resp.ReturnMsg), zap.String("result_code", resp.ResultCode), zap.String("err_code", resp.ErrCode), zap.String("err_code_des", resp.ErrCodeDes))
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败")
}
if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil {
s.logger.Error("微信 v2 查单响应验签失败", zap.String("order_no", orderNo), zap.Error(err))
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付订单失败")
}
info := &OrderInfo{
TransactionID: resp.TransactionID, OutTradeNo: resp.OutTradeNo, TradeState: resp.TradeState,
TradeStateDesc: resp.TradeStateDesc, SuccessTime: resp.TimeEnd, TradeType: resp.TradeType,
BankType: resp.BankType, Attach: resp.Attach, PayerOpenID: resp.OpenID,
TotalAmount: resp.TotalFee, PayerTotal: resp.CashFee, Currency: resp.FeeType,
}
if info.Currency == "" {
info.Currency = "CNY"
}
s.logger.Debug("查询微信 v2 订单成功", zap.String("order_no", orderNo), zap.String("trade_state", resp.TradeState))
return info, nil
}
// postV2XML 发送微信支付 v2 XML 请求
func (s *PaymentV2Service) postV2XML(ctx context.Context, endpoint string, body any) ([]byte, error) {
xmlBytes, err := xml.Marshal(body)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(xmlBytes)))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/xml")
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("微信支付接口返回 HTTP 状态码 %d", httpResp.StatusCode)
}
return io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
}
func v2WebsiteURL(notifyURL string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(notifyURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("微信支付回调地址无效")
}
return parsed.Scheme + "://" + parsed.Host, nil
}
func verifyV2ResponseSign(body []byte, apiKey string) error {
decoder := xml.NewDecoder(bytes.NewReader(body))
params := make(map[string]string)
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local == "xml" {
continue
}
var value string
if err = decoder.DecodeElement(&value, &start); err != nil {
return err
}
params[start.Name.Local] = value
}
received := strings.ToUpper(params["sign"])
if received == "" {
return fmt.Errorf("微信支付响应缺少签名")
}
delete(params, "sign")
expected := v2SignMD5(params, apiKey)
if subtle.ConstantTimeCompare([]byte(received), []byte(expected)) != 1 {
return fmt.Errorf("微信支付响应签名不匹配")
}
return 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()))))
}
// v2CallbackNotify v2 支付回调通知 XML 结构
// 所有字段均为 string避免 CDATA 整数字段的类型转换问题
type v2CallbackNotify struct {
XMLName xml.Name `xml:"xml"`
AppID string `xml:"appid"`
BankType string `xml:"bank_type"`
CashFee string `xml:"cash_fee"`
FeeType string `xml:"fee_type"`
IsSubscribe string `xml:"is_subscribe"`
MchID string `xml:"mch_id"`
NonceStr string `xml:"nonce_str"`
OpenID string `xml:"openid"`
OutTradeNo string `xml:"out_trade_no"`
ResultCode string `xml:"result_code"`
ReturnCode string `xml:"return_code"`
Sign string `xml:"sign"`
TimeEnd string `xml:"time_end"`
TotalFee string `xml:"total_fee"`
TradeType string `xml:"trade_type"`
TransactionID string `xml:"transaction_id"`
}
// PeekOrderNo 从 v2 回调 XML 中解析订单号(不验签)
// 用于在验签前确定使用哪套支付配置
func PeekOrderNo(body []byte) (string, error) {
var notify v2CallbackNotify
if err := xml.Unmarshal(body, &notify); err != nil {
return "", fmt.Errorf("解析回调 XML 失败: %w", err)
}
return notify.OutTradeNo, nil
}
// V2CallbackResult v2 回调解析结果
type V2CallbackResult struct {
OutTradeNo string
TransactionID string
// TradeState 为 "SUCCESS" 表示支付成功return_code 和 result_code 均为 SUCCESS
TradeState string
TotalFee string
OpenID string
SuccessTime string
}
// VerifyCallback 解析并验证 v2 回调 XML返回解析结果
func (s *PaymentV2Service) VerifyCallback(body []byte) (*V2CallbackResult, error) {
var notify v2CallbackNotify
if err := xml.Unmarshal(body, &notify); err != nil {
return nil, fmt.Errorf("解析回调 XML 失败: %w", err)
}
// 构建待签名参数(排除 sign 字段v2SignMD5 内部自动跳过空值)
params := map[string]string{
"appid": notify.AppID,
"bank_type": notify.BankType,
"cash_fee": notify.CashFee,
"fee_type": notify.FeeType,
"is_subscribe": notify.IsSubscribe,
"mch_id": notify.MchID,
"nonce_str": notify.NonceStr,
"openid": notify.OpenID,
"out_trade_no": notify.OutTradeNo,
"result_code": notify.ResultCode,
"return_code": notify.ReturnCode,
"time_end": notify.TimeEnd,
"total_fee": notify.TotalFee,
"trade_type": notify.TradeType,
"transaction_id": notify.TransactionID,
}
expected := v2SignMD5(params, s.apiKey)
if !strings.EqualFold(notify.Sign, expected) {
s.logger.Error("v2 回调签名验证失败",
zap.String("received", notify.Sign),
zap.String("expected", expected),
zap.String("mch_id", notify.MchID),
)
return nil, fmt.Errorf("签名验证失败")
}
// return_code 和 result_code 均为 SUCCESS 才算支付成功
tradeState := ""
if notify.ReturnCode == "SUCCESS" && notify.ResultCode == "SUCCESS" {
tradeState = "SUCCESS"
}
return &V2CallbackResult{
OutTradeNo: notify.OutTradeNo,
TransactionID: notify.TransactionID,
TradeState: tradeState,
TotalFee: notify.TotalFee,
OpenID: notify.OpenID,
SuccessTime: notify.TimeEnd,
}, nil
}