Files
junhong_cmp_fiber/pkg/wechat/payment_v2.go
邹进 7b13431f36
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
fix: 微信 H5 下单被明确拒绝时报真实原因并关单,不再显示请求超时
微信 v2 统一下单返回 result_code=FAIL(如 NOAUTH 商户未开通 H5 产品)时订单未创建,
原来一律按结果未知返回"请求超时"、订单挂到支付截止才关。改为验签后按明确失败返回微信原因,
调用方立即关单;用微信配置不可用错误码,不触发商户自动停用。ORDERPAID、OUT_TRADE_NO_USED、SYSTEMERROR 仍按未知处理。
2026-11-07 22:12:42 +09:00

566 lines
20 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"
stderrors "errors"
"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
// 退款接口需要双向证书,因此单独持有带客户端证书的 HTTP 客户端;
// 未配置证书时 refundClient 为 nil,退款能力按凭证不完整判定为不可用。
refundClient *http.Client
clientCertPEM string
clientKeyPEM string
}
// NewPaymentV2Service 创建微信支付 v2 服务
// ErrOrderNotExist 表示微信明确返回订单不存在(v2 err_code=ORDERNOTEXIST)。
var ErrOrderNotExist = stderrors.New("微信订单不存在")
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"`
}
// V2BusinessError 是微信 v2 统一下单明确返回业务失败(result_code=FAIL),订单未在微信侧创建。
// 调用方据此直接报真实原因并关闭本地订单,而不是按"结果未知"等待查单。
type V2BusinessError struct {
Code string
Message string
}
func (e *V2BusinessError) Error() string {
return fmt.Sprintf("微信支付下单被拒绝:%s(%s)", e.Message, e.Code)
}
// v2UnifiedOrderUncertainCodes 是业务失败但订单可能已存在或结果不确定的错误码,仍按结果未知处理。
var v2UnifiedOrderUncertainCodes = map[string]bool{"ORDERPAID": true, "OUT_TRADE_NO_USED": true, "SYSTEMERROR": true}
// v2BusinessFailure 判断统一下单响应是否为明确的业务失败;只有签名校验通过的响应才认定,避免伪造响应误关单。
func v2BusinessFailure(resp v2UnifiedOrderResponse, respBytes []byte, apiKey string) *V2BusinessError {
if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "FAIL" || resp.ErrCode == "" || v2UnifiedOrderUncertainCodes[resp.ErrCode] {
return nil
}
if verifyV2ResponseSign(respBytes, apiKey) != nil {
return nil
}
message := resp.ErrCodeDes
if message == "" {
message = resp.ErrCode
}
return &V2BusinessError{Code: resp.ErrCode, Message: message}
}
// 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))
if bizErr := v2BusinessFailure(resp, respBytes, s.apiKey); bizErr != nil {
return nil, bizErr
}
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" && resp.ErrCode == "ORDERNOTEXIST" {
// 微信明确表示没有这笔订单:返回专门的错误,由调用方按"渠道无此订单"处理,不当作结果未知反复重查。
s.logger.Warn("微信 v2 查单:订单不存在", zap.String("order_no", orderNo))
return nil, ErrOrderNotExist
}
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
}