Files
junhong_cmp_fiber/pkg/wechat/payment_v2.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

524 lines
18 KiB
Go
Raw Permalink 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 (
"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
// 退款接口需要双向证书,因此单独持有带客户端证书的 HTTP 客户端;
// 未配置证书时 refundClient 为 nil退款能力按凭证不完整判定为不可用。
refundClient *http.Client
clientCertPEM string
clientKeyPEM string
}
// 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, &notify); 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, &notify); 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
}