Introduce a sentinel ErrOrderNotFound in the payment service layer so the
webhook handler can distinguish "the out_trade_no does not exist in our DB"
from other fulfillment failures, and downgrade the former to a WARN log +
success response.
Background
- Providers (Stripe, Alipay, Wxpay, EasyPay, ...) retry webhooks whenever
we answer non-2xx. When a webhook endpoint is misconfigured (e.g. a
foreign environment points at us) or our orders table has been wiped,
we return 500 forever and the provider retries for days, spamming logs.
- The old code also collapsed "order not found" and "DB query failed" into
the same branch — a DB blip would be reported as "order not found" and
swallowed.
Service layer (payment_fulfillment.go)
- Add `var ErrOrderNotFound = errors.New("payment order not found")`.
- In HandlePaymentNotification, distinguish the two error paths:
* dbent.IsNotFound(err) → wrap with ErrOrderNotFound so callers can
errors.Is(...) it.
* anything else → wrap the original err with %w so it still bubbles up
as 500 and the provider retries (DB hiccup should be retried).
Handler layer (payment_webhook_handler.go)
- Before returning 500, check errors.Is(err, service.ErrOrderNotFound):
emit a WARN (with provider / outTradeNo / tradeNo for discoverability),
then call writeSuccessResponse so the provider sees its expected 2xx
body (Stripe empty body / Wxpay JSON / others "success").
- Other errors retain the existing 500 behavior.
Monitoring note: because this path now swallows unknown-order webhooks
silently from the provider's perspective, the WARN log line is the only
signal. Alert on "unknown order, acking to stop retries" if you want
visibility into misrouted webhooks or accidental data loss.
199 lines
6.7 KiB
Go
199 lines
6.7 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// PaymentWebhookHandler handles payment provider webhook callbacks.
|
|
type PaymentWebhookHandler struct {
|
|
paymentService *service.PaymentService
|
|
registry *payment.Registry
|
|
}
|
|
|
|
// maxWebhookBodySize is the maximum allowed webhook request body size (1 MB).
|
|
const maxWebhookBodySize = 1 << 20
|
|
|
|
// webhookLogTruncateLen is the maximum length of raw body logged on verify failure.
|
|
const webhookLogTruncateLen = 200
|
|
|
|
// NewPaymentWebhookHandler creates a new PaymentWebhookHandler.
|
|
func NewPaymentWebhookHandler(paymentService *service.PaymentService, registry *payment.Registry) *PaymentWebhookHandler {
|
|
return &PaymentWebhookHandler{
|
|
paymentService: paymentService,
|
|
registry: registry,
|
|
}
|
|
}
|
|
|
|
// EasyPayNotify handles EasyPay payment notifications.
|
|
// POST /api/v1/payment/webhook/easypay
|
|
func (h *PaymentWebhookHandler) EasyPayNotify(c *gin.Context) {
|
|
h.handleNotify(c, payment.TypeEasyPay)
|
|
}
|
|
|
|
// AlipayNotify handles Alipay payment notifications.
|
|
// POST /api/v1/payment/webhook/alipay
|
|
func (h *PaymentWebhookHandler) AlipayNotify(c *gin.Context) {
|
|
h.handleNotify(c, payment.TypeAlipay)
|
|
}
|
|
|
|
// WxpayNotify handles WeChat Pay payment notifications.
|
|
// POST /api/v1/payment/webhook/wxpay
|
|
func (h *PaymentWebhookHandler) WxpayNotify(c *gin.Context) {
|
|
h.handleNotify(c, payment.TypeWxpay)
|
|
}
|
|
|
|
// StripeWebhook handles Stripe webhook events.
|
|
// POST /api/v1/payment/webhook/stripe
|
|
func (h *PaymentWebhookHandler) StripeWebhook(c *gin.Context) {
|
|
h.handleNotify(c, payment.TypeStripe)
|
|
}
|
|
|
|
// handleNotify is the shared logic for all provider webhook handlers.
|
|
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
|
|
var rawBody string
|
|
if c.Request.Method == http.MethodGet {
|
|
// GET callbacks (e.g. EasyPay) pass params as URL query string
|
|
rawBody = c.Request.URL.RawQuery
|
|
} else {
|
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxWebhookBodySize))
|
|
if err != nil {
|
|
slog.Error("[Payment Webhook] failed to read body", "provider", providerKey, "error", err)
|
|
c.String(http.StatusBadRequest, "failed to read body")
|
|
return
|
|
}
|
|
rawBody = string(body)
|
|
}
|
|
|
|
// 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)
|
|
|
|
providers, err := h.paymentService.GetWebhookProviders(c.Request.Context(), providerKey, outTradeNo)
|
|
if err != nil {
|
|
slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err)
|
|
if providerKey == payment.TypeWxpay {
|
|
c.String(http.StatusBadRequest, "verify failed")
|
|
return
|
|
}
|
|
writeSuccessResponse(c, providerKey)
|
|
return
|
|
}
|
|
|
|
headers := make(map[string]string)
|
|
for k := range c.Request.Header {
|
|
headers[strings.ToLower(k)] = c.GetHeader(k)
|
|
}
|
|
|
|
resolvedProviderKey, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
|
|
if err != nil {
|
|
truncatedBody := rawBody
|
|
if len(truncatedBody) > webhookLogTruncateLen {
|
|
truncatedBody = truncatedBody[:webhookLogTruncateLen] + "...(truncated)"
|
|
}
|
|
slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err, "method", c.Request.Method, "bodyLen", len(rawBody))
|
|
slog.Debug("[Payment Webhook] verify failed body", "provider", providerKey, "rawBody", truncatedBody)
|
|
c.String(http.StatusBadRequest, "verify failed")
|
|
return
|
|
}
|
|
|
|
// nil notification means irrelevant event (e.g. Stripe non-payment event); return success.
|
|
if notification == nil {
|
|
writeSuccessResponse(c, resolvedProviderKey)
|
|
return
|
|
}
|
|
|
|
if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, resolvedProviderKey); err != nil {
|
|
// Unknown order: ack with 2xx so the provider stops retrying. This
|
|
// guards against foreign environments whose webhook endpoints are
|
|
// (mis)configured to point at us — without a 2xx, the provider will
|
|
// retry for days and spam our error logs. We still emit a WARN so the
|
|
// event is discoverable in logs.
|
|
if errors.Is(err, service.ErrOrderNotFound) {
|
|
slog.Warn("[Payment Webhook] unknown order, acking to stop retries",
|
|
"provider", resolvedProviderKey,
|
|
"outTradeNo", notification.OrderID,
|
|
"tradeNo", notification.TradeNo,
|
|
)
|
|
writeSuccessResponse(c, resolvedProviderKey)
|
|
return
|
|
}
|
|
slog.Error("[Payment Webhook] handle notification failed", "provider", resolvedProviderKey, "error", err)
|
|
c.String(http.StatusInternalServerError, "handle failed")
|
|
return
|
|
}
|
|
|
|
writeSuccessResponse(c, resolvedProviderKey)
|
|
}
|
|
|
|
// 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, payment.TypeAlipay:
|
|
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 ""
|
|
}
|
|
|
|
func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (string, *payment.PaymentNotification, error) {
|
|
var lastErr error
|
|
for _, provider := range providers {
|
|
if provider == nil {
|
|
continue
|
|
}
|
|
notification, err := provider.VerifyNotification(ctx, rawBody, headers)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return provider.ProviderKey(), notification, nil
|
|
}
|
|
if lastErr != nil {
|
|
return "", nil, lastErr
|
|
}
|
|
return "", nil, fmt.Errorf("no webhook provider could verify notification")
|
|
}
|
|
|
|
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
|
|
type wxpaySuccessResponse struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// WeChat Pay webhook success response constants.
|
|
const (
|
|
wxpaySuccessCode = "SUCCESS"
|
|
wxpaySuccessMessage = "成功"
|
|
)
|
|
|
|
// writeSuccessResponse sends the provider-specific success response.
|
|
// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"};
|
|
// Stripe expects an empty 200; others accept plain text "success".
|
|
func writeSuccessResponse(c *gin.Context, providerKey string) {
|
|
switch providerKey {
|
|
case payment.TypeWxpay:
|
|
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
|
|
case payment.TypeStripe:
|
|
c.String(http.StatusOK, "")
|
|
default:
|
|
c.String(http.StatusOK, "success")
|
|
}
|
|
}
|