Files
junhong_cmp_fiber/pkg/wechat/payment_v2.go
邹进 d2b0d2f534
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
fix: 排队激活未评估停复机、孤儿套餐重复激活与微信查单订单不存在反复重查
- 排队激活与首次实名激活任务的处理器注入停复机服务:原来传 nil,激活后不重新评估停复机,付款或实名后要等下一轮轮询才复机(生产同样存在)
- 孤儿套餐扫描跳过载体未实名的"实名即生效"套餐:激活服务会直接跳过,原来每 10 秒重复提交一次激活任务
- 微信 v2 查单返回 ORDERNOTEXIST 时按"渠道无此订单"处理:代理充值支付单过期后关闭,不再当作结果未知每几分钟重查

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:07:17 +09:00

534 lines
19 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"`
}
// 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" && 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
}