Files
junhong_cmp_fiber/pkg/wechat/payment_v2_refund.go
break ba0855d9eb feat(退款): AUG26-006 退款方式选择与原路退款
按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00

311 lines
13 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 (
"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))
}