| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216 |
- package handler
- import (
- "context"
- "encoding/json"
- "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)
- }
- // AirwallexWebhook 处理空中云汇 Webhook 事件。
- // POST /api/v1/payment/webhook/airwallex
- func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) {
- h.handleNotify(c, payment.TypeAirwallex)
- }
- // 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")
- }
- case payment.TypeAirwallex:
- var payload struct {
- Data struct {
- Object struct {
- MerchantOrderID string `json:"merchant_order_id"`
- } `json:"object"`
- } `json:"data"`
- }
- if err := json.Unmarshal([]byte(rawBody), &payload); err == nil {
- return strings.TrimSpace(payload.Data.Object.MerchantOrderID)
- }
- }
- // 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 返回各支付服务商要求的成功响应。
- // 微信支付需要 JSON {"code":"SUCCESS","message":"成功"};
- // Stripe 和空中云汇接受空 200,其它服务商接受纯文本 "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, payment.TypeAirwallex:
- c.String(http.StatusOK, "")
- default:
- c.String(http.StatusOK, "success")
- }
- }
|