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: 按用户要求未运行自动化测试及真实支付联调
519 lines
18 KiB
Go
519 lines
18 KiB
Go
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, ¬ify); 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, ¬ify); 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
|
||
}
|