让代理充值复用现有网页支付能力
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: 按用户要求未运行自动化测试及真实支付联调
This commit is contained in:
2026-07-30 11:41:50 +08:00
parent 0f4f0d1176
commit 8fc667daee
24 changed files with 692 additions and 352 deletions

View File

@@ -115,7 +115,7 @@ func (l *paymentConfigLoader) buildPaymentService(config *model.WechatConfig) (w
// v2PaymentAdapter 微信支付 v2 适配器
// 将 PaymentV2Service 适配为完整的 PaymentServiceInterface
// v2 支持 JSAPI 支付,其余方法返回不支持错误
// v2 支持 JSAPI、H5/MWEB 与查单,其余方法返回不支持错误
type v2PaymentAdapter struct {
svc *wechat.PaymentV2Service
}
@@ -124,12 +124,12 @@ func (a *v2PaymentAdapter) CreateJSAPIOrder(ctx context.Context, orderNo, descri
return a.svc.CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
}
func (a *v2PaymentAdapter) CreateH5Order(_ context.Context, _ string, _ string, _ int, _ *wechat.H5SceneInfo) (*wechat.H5PayResult, error) {
return nil, errors.New(errors.CodeWechatPayFailed, "微信支付 v2 不支持 H5 支付")
func (a *v2PaymentAdapter) CreateH5Order(ctx context.Context, orderNo, description string, amount int, sceneInfo *wechat.H5SceneInfo) (*wechat.H5PayResult, error) {
return a.svc.CreateH5Order(ctx, orderNo, description, amount, sceneInfo)
}
func (a *v2PaymentAdapter) QueryOrder(_ context.Context, _ string) (*wechat.OrderInfo, error) {
return nil, errors.New(errors.CodeWechatPayFailed, "微信支付 v2 暂不支持查单接口")
func (a *v2PaymentAdapter) QueryOrder(ctx context.Context, orderNo string) (*wechat.OrderInfo, error) {
return a.svc.QueryOrder(ctx, orderNo)
}
func (a *v2PaymentAdapter) CloseOrder(_ context.Context, _ string) error {

View File

@@ -1,21 +1,29 @@
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"
const (
wechatPayV2UnifiedOrderURL = "https://api.mch.weixin.qq.com/pay/unifiedorder"
wechatPayV2OrderQueryURL = "https://api.mch.weixin.qq.com/pay/orderquery"
)
// PaymentV2Service 微信支付 v2 服务
// 适用于仅配置 APIv2Key 的商户(无需 v3 证书序列号)
@@ -51,7 +59,8 @@ type v2UnifiedOrderRequest struct {
SpbillIP string `xml:"spbill_create_ip"`
NotifyURL string `xml:"notify_url"`
TradeType string `xml:"trade_type"`
OpenID string `xml:"openid"`
OpenID string `xml:"openid,omitempty"`
SceneInfo string `xml:"scene_info,omitempty"`
}
// v2UnifiedOrderResponse 统一下单响应v2 XML 格式)
@@ -63,6 +72,51 @@ type v2UnifiedOrderResponse struct {
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 支付订单
@@ -158,6 +212,172 @@ func (s *PaymentV2Service) CreateJSAPIOrder(ctx context.Context, orderNo, descri
}, 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())