package handler import ( "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) provider, err := h.paymentService.GetWebhookProvider(c.Request.Context(), providerKey, outTradeNo) if err != nil { slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err) writeSuccessResponse(c, providerKey) return } headers := make(map[string]string) for k := range c.Request.Header { headers[strings.ToLower(k)] = c.GetHeader(k) } notification, err := provider.VerifyNotification(c.Request.Context(), 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, providerKey) return } if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, providerKey); err != nil { slog.Error("[Payment Webhook] handle notification failed", "provider", providerKey, "error", err) c.String(http.StatusInternalServerError, "handle failed") return } 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"` 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") } }