Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user