fix: 微信 H5 下单被明确拒绝时报真实原因并关单,不再显示请求超时
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

微信 v2 统一下单返回 result_code=FAIL(如 NOAUTH 商户未开通 H5 产品)时订单未创建,
原来一律按结果未知返回"请求超时"、订单挂到支付截止才关。改为验签后按明确失败返回微信原因,
调用方立即关单;用微信配置不可用错误码,不触发商户自动停用。ORDERPAID、OUT_TRADE_NO_USED、SYSTEMERROR 仍按未知处理。
This commit is contained in:
邹进
2026-11-07 22:12:42 +09:00
parent b2fc74c021
commit 7b13431f36
3 changed files with 95 additions and 0 deletions

View File

@@ -47,6 +47,18 @@ func (a *WechatWebAdapter) CreatePaymentURL(ctx context.Context, request agentre
PayerClientIP: request.PayerClientIP,
H5Type: "Wap",
})
// 微信明确拒绝(订单未创建):记失败并返回真实原因,调用方立即关单;
// 用"微信配置不可用"错误码,不触发支付失败自动停用商户(如 H5 产品未开通不影响该商户的公众号支付)。
var bizErr *wechatpay.V2BusinessError
if stderrors.As(callErr, &bizErr) {
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: bizErr.Code, ProviderMessage: bizErr.Message,
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
}); err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
return agentrecharge.OnlinePaymentResult{}, apperrors.New(apperrors.CodeWechatConfigUnavailable, bizErr.Error())
}
if callErr != nil {
return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}

View File

@@ -85,6 +85,35 @@ type v2UnifiedOrderResponse struct {
MwebURL string `xml:"mweb_url"`
}
// V2BusinessError 是微信 v2 统一下单明确返回业务失败(result_code=FAIL),订单未在微信侧创建。
// 调用方据此直接报真实原因并关闭本地订单,而不是按"结果未知"等待查单。
type V2BusinessError struct {
Code string
Message string
}
func (e *V2BusinessError) Error() string {
return fmt.Sprintf("微信支付下单被拒绝:%s(%s)", e.Message, e.Code)
}
// v2UnifiedOrderUncertainCodes 是业务失败但订单可能已存在或结果不确定的错误码,仍按结果未知处理。
var v2UnifiedOrderUncertainCodes = map[string]bool{"ORDERPAID": true, "OUT_TRADE_NO_USED": true, "SYSTEMERROR": true}
// v2BusinessFailure 判断统一下单响应是否为明确的业务失败;只有签名校验通过的响应才认定,避免伪造响应误关单。
func v2BusinessFailure(resp v2UnifiedOrderResponse, respBytes []byte, apiKey string) *V2BusinessError {
if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "FAIL" || resp.ErrCode == "" || v2UnifiedOrderUncertainCodes[resp.ErrCode] {
return nil
}
if verifyV2ResponseSign(respBytes, apiKey) != nil {
return nil
}
message := resp.ErrCodeDes
if message == "" {
message = resp.ErrCode
}
return &V2BusinessError{Code: resp.ErrCode, Message: message}
}
// v2OrderQueryRequest 商户订单号查单请求(v2 XML 格式)
type v2OrderQueryRequest struct {
XMLName xml.Name `xml:"xml"`
@@ -266,6 +295,9 @@ func (s *PaymentV2Service) CreateH5Order(ctx context.Context, orderNo, descripti
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))
if bizErr := v2BusinessFailure(resp, respBytes, s.apiKey); bizErr != nil {
return nil, bizErr
}
return nil, errors.New(errors.CodeWechatPayFailed, "创建 H5 支付订单失败")
}
if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil {

View File

@@ -0,0 +1,51 @@
package wechat
import (
"encoding/xml"
"fmt"
"testing"
)
func signedUnifiedOrderResponse(apiKey string, fields map[string]string) ([]byte, v2UnifiedOrderResponse) {
fields["sign"] = v2SignMD5(fields, apiKey)
body := "<xml>"
for key, value := range fields {
body += fmt.Sprintf("<%s><![CDATA[%s]]></%s>", key, value, key)
}
body += "</xml>"
var resp v2UnifiedOrderResponse
_ = xml.Unmarshal([]byte(body), &resp)
return []byte(body), resp
}
func TestV2BusinessFailure(t *testing.T) {
const key = "test-api-key-0123456789abcdef01234"
noauth := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "FAIL",
"err_code": "NOAUTH", "err_code_des": "商户号该产品权限未开通", "nonce_str": "n1"}
body, resp := signedUnifiedOrderResponse(key, noauth)
got := v2BusinessFailure(resp, body, key)
if got == nil || got.Code != "NOAUTH" || got.Message != "商户号该产品权限未开通" {
t.Fatalf("NOAUTH should be explicit failure, got %+v", got)
}
if got.Error() != "微信支付下单被拒绝:商户号该产品权限未开通(NOAUTH)" {
t.Fatalf("unexpected message %q", got.Error())
}
if v2BusinessFailure(resp, body, "wrong-key-0123456789abcdef0123456") != nil {
t.Fatal("unsigned / wrongly signed response must not be treated as explicit failure")
}
for _, code := range []string{"ORDERPAID", "OUT_TRADE_NO_USED", "SYSTEMERROR"} {
fields := map[string]string{"return_code": "SUCCESS", "result_code": "FAIL", "err_code": code, "nonce_str": "n2"}
b, r := signedUnifiedOrderResponse(key, fields)
if v2BusinessFailure(r, b, key) != nil {
t.Fatalf("%s must stay uncertain", code)
}
}
commFail := map[string]string{"return_code": "FAIL", "return_msg": "签名错误", "nonce_str": "n3"}
b, r := signedUnifiedOrderResponse(key, commFail)
if v2BusinessFailure(r, b, key) != nil {
t.Fatal("return_code FAIL is a communication failure, not an explicit business failure")
}
}