payment_webhook_handler.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "net/http"
  10. "net/url"
  11. "strings"
  12. "github.com/Wei-Shaw/sub2api/internal/payment"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. )
  16. // PaymentWebhookHandler handles payment provider webhook callbacks.
  17. type PaymentWebhookHandler struct {
  18. paymentService *service.PaymentService
  19. registry *payment.Registry
  20. }
  21. // maxWebhookBodySize is the maximum allowed webhook request body size (1 MB).
  22. const maxWebhookBodySize = 1 << 20
  23. // webhookLogTruncateLen is the maximum length of raw body logged on verify failure.
  24. const webhookLogTruncateLen = 200
  25. // NewPaymentWebhookHandler creates a new PaymentWebhookHandler.
  26. func NewPaymentWebhookHandler(paymentService *service.PaymentService, registry *payment.Registry) *PaymentWebhookHandler {
  27. return &PaymentWebhookHandler{
  28. paymentService: paymentService,
  29. registry: registry,
  30. }
  31. }
  32. // EasyPayNotify handles EasyPay payment notifications.
  33. // POST /api/v1/payment/webhook/easypay
  34. func (h *PaymentWebhookHandler) EasyPayNotify(c *gin.Context) {
  35. h.handleNotify(c, payment.TypeEasyPay)
  36. }
  37. // AlipayNotify handles Alipay payment notifications.
  38. // POST /api/v1/payment/webhook/alipay
  39. func (h *PaymentWebhookHandler) AlipayNotify(c *gin.Context) {
  40. h.handleNotify(c, payment.TypeAlipay)
  41. }
  42. // WxpayNotify handles WeChat Pay payment notifications.
  43. // POST /api/v1/payment/webhook/wxpay
  44. func (h *PaymentWebhookHandler) WxpayNotify(c *gin.Context) {
  45. h.handleNotify(c, payment.TypeWxpay)
  46. }
  47. // StripeWebhook handles Stripe webhook events.
  48. // POST /api/v1/payment/webhook/stripe
  49. func (h *PaymentWebhookHandler) StripeWebhook(c *gin.Context) {
  50. h.handleNotify(c, payment.TypeStripe)
  51. }
  52. // AirwallexWebhook 处理空中云汇 Webhook 事件。
  53. // POST /api/v1/payment/webhook/airwallex
  54. func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) {
  55. h.handleNotify(c, payment.TypeAirwallex)
  56. }
  57. // handleNotify is the shared logic for all provider webhook handlers.
  58. func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
  59. var rawBody string
  60. if c.Request.Method == http.MethodGet {
  61. // GET callbacks (e.g. EasyPay) pass params as URL query string
  62. rawBody = c.Request.URL.RawQuery
  63. } else {
  64. body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxWebhookBodySize))
  65. if err != nil {
  66. slog.Error("[Payment Webhook] failed to read body", "provider", providerKey, "error", err)
  67. c.String(http.StatusBadRequest, "failed to read body")
  68. return
  69. }
  70. rawBody = string(body)
  71. }
  72. // Extract out_trade_no to look up the order's specific provider instance.
  73. // This is needed when multiple instances of the same provider exist (e.g. multiple EasyPay accounts).
  74. outTradeNo := extractOutTradeNo(rawBody, providerKey)
  75. providers, err := h.paymentService.GetWebhookProviders(c.Request.Context(), providerKey, outTradeNo)
  76. if err != nil {
  77. slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err)
  78. if providerKey == payment.TypeWxpay {
  79. c.String(http.StatusBadRequest, "verify failed")
  80. return
  81. }
  82. writeSuccessResponse(c, providerKey)
  83. return
  84. }
  85. headers := make(map[string]string)
  86. for k := range c.Request.Header {
  87. headers[strings.ToLower(k)] = c.GetHeader(k)
  88. }
  89. resolvedProviderKey, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
  90. if err != nil {
  91. truncatedBody := rawBody
  92. if len(truncatedBody) > webhookLogTruncateLen {
  93. truncatedBody = truncatedBody[:webhookLogTruncateLen] + "...(truncated)"
  94. }
  95. slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err, "method", c.Request.Method, "bodyLen", len(rawBody))
  96. slog.Debug("[Payment Webhook] verify failed body", "provider", providerKey, "rawBody", truncatedBody)
  97. c.String(http.StatusBadRequest, "verify failed")
  98. return
  99. }
  100. // nil notification means irrelevant event (e.g. Stripe non-payment event); return success.
  101. if notification == nil {
  102. writeSuccessResponse(c, resolvedProviderKey)
  103. return
  104. }
  105. if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, resolvedProviderKey); err != nil {
  106. // Unknown order: ack with 2xx so the provider stops retrying. This
  107. // guards against foreign environments whose webhook endpoints are
  108. // (mis)configured to point at us — without a 2xx, the provider will
  109. // retry for days and spam our error logs. We still emit a WARN so the
  110. // event is discoverable in logs.
  111. if errors.Is(err, service.ErrOrderNotFound) {
  112. slog.Warn("[Payment Webhook] unknown order, acking to stop retries",
  113. "provider", resolvedProviderKey,
  114. "outTradeNo", notification.OrderID,
  115. "tradeNo", notification.TradeNo,
  116. )
  117. writeSuccessResponse(c, resolvedProviderKey)
  118. return
  119. }
  120. slog.Error("[Payment Webhook] handle notification failed", "provider", resolvedProviderKey, "error", err)
  121. c.String(http.StatusInternalServerError, "handle failed")
  122. return
  123. }
  124. writeSuccessResponse(c, resolvedProviderKey)
  125. }
  126. // extractOutTradeNo parses the webhook body to find the out_trade_no.
  127. // This allows looking up the correct provider instance before verification.
  128. func extractOutTradeNo(rawBody, providerKey string) string {
  129. switch providerKey {
  130. case payment.TypeEasyPay, payment.TypeAlipay:
  131. values, err := url.ParseQuery(rawBody)
  132. if err == nil {
  133. return values.Get("out_trade_no")
  134. }
  135. case payment.TypeAirwallex:
  136. var payload struct {
  137. Data struct {
  138. Object struct {
  139. MerchantOrderID string `json:"merchant_order_id"`
  140. } `json:"object"`
  141. } `json:"data"`
  142. }
  143. if err := json.Unmarshal([]byte(rawBody), &payload); err == nil {
  144. return strings.TrimSpace(payload.Data.Object.MerchantOrderID)
  145. }
  146. }
  147. // For other providers (Stripe, Alipay direct, WxPay direct), the registry
  148. // typically has only one instance, so no instance lookup is needed.
  149. return ""
  150. }
  151. func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (string, *payment.PaymentNotification, error) {
  152. var lastErr error
  153. for _, provider := range providers {
  154. if provider == nil {
  155. continue
  156. }
  157. notification, err := provider.VerifyNotification(ctx, rawBody, headers)
  158. if err != nil {
  159. lastErr = err
  160. continue
  161. }
  162. return provider.ProviderKey(), notification, nil
  163. }
  164. if lastErr != nil {
  165. return "", nil, lastErr
  166. }
  167. return "", nil, fmt.Errorf("no webhook provider could verify notification")
  168. }
  169. // wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
  170. type wxpaySuccessResponse struct {
  171. Code string `json:"code"`
  172. Message string `json:"message"`
  173. }
  174. // WeChat Pay webhook success response constants.
  175. const (
  176. wxpaySuccessCode = "SUCCESS"
  177. wxpaySuccessMessage = "成功"
  178. )
  179. // writeSuccessResponse 返回各支付服务商要求的成功响应。
  180. // 微信支付需要 JSON {"code":"SUCCESS","message":"成功"};
  181. // Stripe 和空中云汇接受空 200,其它服务商接受纯文本 "success"。
  182. func writeSuccessResponse(c *gin.Context, providerKey string) {
  183. switch providerKey {
  184. case payment.TypeWxpay:
  185. c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
  186. case payment.TypeStripe, payment.TypeAirwallex:
  187. c.String(http.StatusOK, "")
  188. default:
  189. c.String(http.StatusOK, "success")
  190. }
  191. }