From d642a16f79706fd8543f53f288240862025c2f38 Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 10 Apr 2026 14:03:59 +0800 Subject: [PATCH] fix(payment): webhook uses order's provider instance for signature verification When multiple provider instances exist (e.g. 3 EasyPay accounts), the webhook handler now extracts out_trade_no from the callback, looks up the order, and uses the order's original provider instance for verification instead of picking an arbitrary instance from the registry. --- .../handler/payment_webhook_handler.go | 25 ++++++++++++++++--- backend/internal/service/payment_service.go | 20 +++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index c81c0c7dc1..6d8540d80b 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -4,6 +4,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "github.com/Wei-Shaw/sub2api/internal/payment" @@ -72,12 +73,13 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) rawBody = string(body) } - // Ensure providers are loaded (registry may be empty after restart) - h.paymentService.EnsureProviders(c.Request.Context()) + // Extract out_trade_no to look up the order's specific provider instance. + // This is needed when multiple instances of the same provider exist (e.g. multiple EasyPay accounts). + outTradeNo := extractOutTradeNo(rawBody, providerKey) - provider, err := h.registry.GetProviderByKey(providerKey) + provider, err := h.paymentService.GetWebhookProvider(c.Request.Context(), providerKey, outTradeNo) if err != nil { - slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err) + slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err) writeSuccessResponse(c, providerKey) return } @@ -114,6 +116,21 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) writeSuccessResponse(c, providerKey) } +// extractOutTradeNo parses the webhook body to find the out_trade_no. +// This allows looking up the correct provider instance before verification. +func extractOutTradeNo(rawBody, providerKey string) string { + switch providerKey { + case payment.TypeEasyPay: + values, err := url.ParseQuery(rawBody) + if err == nil { + return values.Get("out_trade_no") + } + } + // For other providers (Stripe, Alipay direct, WxPay direct), the registry + // typically has only one instance, so no instance lookup is needed. + return "" +} + // wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook. type wxpaySuccessResponse struct { Code string `json:"code"` diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 7679eb8d54..25ec15f748 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -9,6 +9,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/payment/provider" @@ -218,6 +219,25 @@ func (s *PaymentService) loadProviders(ctx context.Context) { } } +// GetWebhookProvider returns the provider instance that should verify a webhook. +// It extracts out_trade_no from the raw body, looks up the order to find the +// original provider instance, and creates a provider with that instance's credentials. +// Falls back to the registry provider when the order cannot be found. +func (s *PaymentService) GetWebhookProvider(ctx context.Context, providerKey, outTradeNo string) (payment.Provider, error) { + if outTradeNo != "" { + order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(outTradeNo)).Only(ctx) + if err == nil { + p, pErr := s.getOrderProvider(ctx, order) + if pErr == nil { + return p, nil + } + slog.Warn("[Webhook] order provider creation failed, falling back to registry", "outTradeNo", outTradeNo, "error", pErr) + } + } + s.EnsureProviders(ctx) + return s.registry.GetProviderByKey(providerKey) +} + // --- Helpers --- func psIsRefundStatus(s string) bool {