diff --git a/internal/infrastructure/payment/wechat_web.go b/internal/infrastructure/payment/wechat_web.go index feecfbdf..bf7384b3 100644 --- a/internal/infrastructure/payment/wechat_web.go +++ b/internal/infrastructure/payment/wechat_web.go @@ -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) } diff --git a/pkg/wechat/payment_v2.go b/pkg/wechat/payment_v2.go index f3eafb0b..1a0c662d 100644 --- a/pkg/wechat/payment_v2.go +++ b/pkg/wechat/payment_v2.go @@ -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 { diff --git a/pkg/wechat/payment_v2_business_error_test.go b/pkg/wechat/payment_v2_business_error_test.go new file mode 100644 index 00000000..bdd1b58e --- /dev/null +++ b/pkg/wechat/payment_v2_business_error_test.go @@ -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 := "" + for key, value := range fields { + body += fmt.Sprintf("<%s>", key, value, key) + } + body += "" + 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") + } +}