package wechat import ( "context" "crypto/tls" "encoding/xml" "fmt" "io" "net/http" "strings" "go.uber.org/zap" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // 微信支付 v2 退款相关端点。退款申请为双向证书接口,退款查询不需要证书。 const ( wechatPayV2RefundURL = "https://api.mch.weixin.qq.com/secapi/pay/refund" wechatPayV2RefundQueryURL = "https://api.mch.weixin.qq.com/pay/refundquery" ) // 微信支付 v2 退款状态(refundquery 的 refund_status_$n 取值)。 const ( v2RefundStatusSuccess = "SUCCESS" // 退款成功 v2RefundStatusRefundClose = "REFUNDCLOSE" // 退款关闭 v2RefundStatusProcessing = "PROCESSING" // 退款处理中 v2RefundStatusChange = "CHANGE" // 退款异常,需人工处理 ) // SetClientCertificate 注入 API 客户端证书,用于微信支付 v2 的双向证书退款接口。 // certPEM 为 apiclient_cert.pem 内容,keyPEM 为 apiclient_key.pem 内容。 // 两者任一为空时清除证书,退款能力随之为不可用;支付与查单接口不依赖该证书。 func (s *PaymentV2Service) SetClientCertificate(certPEM, keyPEM string) error { if strings.TrimSpace(certPEM) == "" || strings.TrimSpace(keyPEM) == "" { s.refundClient = nil s.clientCertPEM = "" s.clientKeyPEM = "" return nil } pair, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) if err != nil { return fmt.Errorf("微信支付 v2 客户端证书不可用: %w", err) } s.clientCertPEM = certPEM s.clientKeyPEM = keyPEM s.refundClient = &http.Client{ Transport: &http.Transport{ // 退款接口要求商户出示 API 客户端证书,即双向 TLS。 TLSClientConfig: &tls.Config{ Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12, }, }, } return nil } // HasClientCertificate 报告该实例是否具备可执行双向证书退款请求的客户端证书。 func (s *PaymentV2Service) HasClientCertificate() bool { return s != nil && s.refundClient != nil } // V2RefundRequest 微信支付 v2 原路退款请求。 // 金额单位均为分;结构不包含任何通知地址字段,退款结果只由同步响应与退款查询确认。 type V2RefundRequest struct { OutTradeNo string // 原支付单商户订单号,与 TransactionID 二选一 TransactionID string OutRefundNo string // 商户退款单号 TotalFee int64 // 原订单总金额(分) RefundFee int64 // 本次退款金额(分) RefundDesc string // 退款原因,可选 } // V2RefundResult 微信支付 v2 退款结果。 // // 退款申请接口的返回只代表渠道受理情况,渠道明确要求通过退款查询获取最终结果, // 因此 RefundOrder 的受理成功不等于退款成功(Success 为 false),须由退款查询确认。 type V2RefundResult struct { // Success 仅当渠道明确退款成功时为 true。 Success bool // Accepted 表示渠道已受理该退款申请(申请接口 result_code=SUCCESS)。 Accepted bool RefundID string // 微信退款单号 OutRefundNo string OutTradeNo string TransactionID string // Status 渠道退款状态原文:SUCCESS/REFUNDCLOSE/PROCESSING/CHANGE;申请接口不返回该字段。 Status string RefundFee int64 // ErrCode 渠道业务错误码,仅渠道明确拒绝时非空。 ErrCode string Message string } // v2RefundRequest 退款申请请求(v2 XML 格式)。 type v2RefundRequest struct { XMLName xml.Name `xml:"xml"` AppID string `xml:"appid"` MchID string `xml:"mch_id"` NonceStr string `xml:"nonce_str"` Sign string `xml:"sign"` OutTradeNo string `xml:"out_trade_no,omitempty"` TransactionID string `xml:"transaction_id,omitempty"` OutRefundNo string `xml:"out_refund_no"` TotalFee int64 `xml:"total_fee"` RefundFee int64 `xml:"refund_fee"` RefundDesc string `xml:"refund_desc,omitempty"` } // v2RefundResponse 退款申请响应(v2 XML 格式)。 type v2RefundResponse 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"` TransactionID string `xml:"transaction_id"` OutTradeNo string `xml:"out_trade_no"` OutRefundNo string `xml:"out_refund_no"` RefundID string `xml:"refund_id"` RefundFee int64 `xml:"refund_fee"` TotalFee int64 `xml:"total_fee"` CashRefundFee int64 `xml:"cash_refund_fee"` } // v2RefundQueryRequest 退款查询请求(v2 XML 格式)。 type v2RefundQueryRequest struct { XMLName xml.Name `xml:"xml"` AppID string `xml:"appid"` MchID string `xml:"mch_id"` NonceStr string `xml:"nonce_str"` Sign string `xml:"sign"` OutRefundNo string `xml:"out_refund_no"` } // v2RefundQueryResponse 退款查询响应(v2 XML 格式)。 // 微信按退款笔数下标返回字段,本系统按商户退款单号查询,因此取第 0 笔。 type v2RefundQueryResponse 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"` OutTradeNo string `xml:"out_trade_no"` TransactionID string `xml:"transaction_id"` RefundCount int `xml:"refund_count"` OutRefundNo0 string `xml:"out_refund_no_0"` RefundID0 string `xml:"refund_id_0"` RefundStatus0 string `xml:"refund_status_0"` RefundFee0 int64 `xml:"refund_fee_0"` SettlementRefundFee0 int64 `xml:"settlement_refund_fee_0"` } // RefundOrder 申请微信支付 v2 原路退款(/secapi/pay/refund,双向证书)。 // // 渠道契约明确规定:申请接口的返回仅代表业务受理情况,退款是否成功必须通过退款查询获取, // 因此本方法在渠道受理成功时返回 Accepted=true 且 Success=false;渠道明确拒绝时返回 // ErrCode 与 Message 且不返回 error,只有传输或解析失败才作为 error 返回。 // 本方法不传递任何退款结果通知地址。 func (s *PaymentV2Service) RefundOrderV2(ctx context.Context, req V2RefundRequest) (*V2RefundResult, error) { if req.OutTradeNo == "" && req.TransactionID == "" { return nil, errors.New(errors.CodeInvalidParam, "商户订单号与微信订单号至少填写一个") } if req.OutRefundNo == "" { return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空") } if req.TotalFee <= 0 || req.RefundFee <= 0 || req.RefundFee > req.TotalFee { return nil, errors.New(errors.CodeInvalidParam, "退款金额必须为正且不超过原订单金额") } if !s.HasClientCertificate() { return nil, errors.New(errors.CodeNoPaymentConfig, "微信支付 v2 退款缺少 API 客户端证书") } nonceStr := v2GenerateNonceStr() params := map[string]string{ "appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr, "out_trade_no": req.OutTradeNo, "transaction_id": req.TransactionID, "out_refund_no": req.OutRefundNo, "total_fee": fmt.Sprintf("%d", req.TotalFee), "refund_fee": fmt.Sprintf("%d", req.RefundFee), "refund_desc": req.RefundDesc, } params["sign"] = v2SignMD5(params, s.apiKey) body := &v2RefundRequest{ AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"], Sign: params["sign"], OutTradeNo: params["out_trade_no"], TransactionID: params["transaction_id"], OutRefundNo: params["out_refund_no"], TotalFee: req.TotalFee, RefundFee: req.RefundFee, RefundDesc: req.RefundDesc, } respBytes, err := s.postV2XMLWithClient(ctx, s.refundClient, wechatPayV2RefundURL, body) if err != nil { s.logger.Error("调用微信 v2 退款接口失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err)) return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败") } var resp v2RefundResponse if err = xml.Unmarshal(respBytes, &resp); err != nil { s.logger.Error("解析微信 v2 退款响应失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err)) return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败") } if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil { s.logger.Error("微信 v2 退款响应验签失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err)) return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败") } result := &V2RefundResult{ RefundID: resp.RefundID, OutRefundNo: resp.OutRefundNo, OutTradeNo: resp.OutTradeNo, TransactionID: resp.TransactionID, RefundFee: resp.RefundFee, } if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" { // 渠道明确拒绝受理:返回渠道错误码供调用方归类,不视为传输失败。 result.ErrCode = resp.ErrCode result.Message = strings.TrimSpace(resp.ErrCode + " " + resp.ErrCodeDes + " " + resp.ReturnMsg) s.logger.Warn("微信 v2 退款未被受理", zap.String("out_refund_no", req.OutRefundNo), zap.String("err_code", resp.ErrCode), zap.String("return_msg", resp.ReturnMsg)) return result, nil } // 受理成功:渠道要求以退款查询确认最终结果。 result.Accepted = true result.Message = "渠道已受理退款申请,等待退款查询确认结果" s.logger.Info("微信 v2 退款已受理", zap.String("out_refund_no", req.OutRefundNo), zap.String("refund_id", resp.RefundID)) return result, nil } // QueryRefund 查询微信支付 v2 退款结果(/pay/refundquery,不需要证书)。 // 仅 refund_status=SUCCESS 视为渠道明确退款成功;REFUNDCLOSE 与 CHANGE 视为明确失败; // PROCESSING 或未返回状态保持结果未知。 func (s *PaymentV2Service) QueryRefund(ctx context.Context, outRefundNo string) (*V2RefundResult, error) { if outRefundNo == "" { return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空") } nonceStr := v2GenerateNonceStr() params := map[string]string{ "appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr, "out_refund_no": outRefundNo, } params["sign"] = v2SignMD5(params, s.apiKey) body := &v2RefundQueryRequest{ AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"], Sign: params["sign"], OutRefundNo: params["out_refund_no"], } respBytes, err := s.postV2XML(ctx, wechatPayV2RefundQueryURL, body) if err != nil { s.logger.Error("调用微信 v2 退款查询失败", zap.String("out_refund_no", outRefundNo), zap.Error(err)) return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败") } var resp v2RefundQueryResponse if err = xml.Unmarshal(respBytes, &resp); err != nil { s.logger.Error("解析微信 v2 退款查询响应失败", zap.String("out_refund_no", outRefundNo), zap.Error(err)) return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败") } if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil { s.logger.Error("微信 v2 退款查询响应验签失败", zap.String("out_refund_no", outRefundNo), zap.Error(err)) return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败") } result := &V2RefundResult{ OutRefundNo: resp.OutRefundNo0, RefundID: resp.RefundID0, OutTradeNo: resp.OutTradeNo, TransactionID: resp.TransactionID, RefundFee: resp.RefundFee0, Status: resp.RefundStatus0, } if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" { result.ErrCode = resp.ErrCode result.Message = strings.TrimSpace(resp.ErrCode + " " + resp.ErrCodeDes + " " + resp.ReturnMsg) return result, nil } switch resp.RefundStatus0 { case v2RefundStatusSuccess: result.Success = true result.Message = "退款成功" case v2RefundStatusRefundClose: result.Message = "退款关闭" case v2RefundStatusChange: result.Message = "退款异常,需人工核对退款账户" case v2RefundStatusProcessing: result.Message = "退款处理中" default: result.Message = "退款状态未知" } return result, nil } // postV2XMLWithClient 使用指定 HTTP 客户端发送 v2 XML 请求。 // 退款接口必须使用持有商户 API 客户端证书的客户端,支付与查单沿用默认客户端。 func (s *PaymentV2Service) postV2XMLWithClient(ctx context.Context, client *http.Client, endpoint string, body any) ([]byte, error) { if client == nil { return nil, errors.New(errors.CodeNoPaymentConfig, "微信支付 v2 请求客户端不可用") } 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 := client.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)) }