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.
This commit is contained in:
erio
2026-04-10 14:03:59 +08:00
parent a92c0eabbc
commit d642a16f79
2 changed files with 41 additions and 4 deletions
@@ -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"`
@@ -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 {