按 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 与行为核对,未调用真实渠道。
311 lines
13 KiB
Go
311 lines
13 KiB
Go
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))
|
||
}
|