mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
Order query:
- 彩虹易支付 and ZPay read act=order parameters from the URL query only;
a body-only request is answered with "No Act!", so order status queries
(reconcile, payment status checks) never reached the order. Parameters
are now sent in the URL query and mirrored in the form body for forks
that read the body.
- Decode status/money/trade_no leniently: PHP deployments commonly return
integers as strings ("status":"1"), which previously failed the whole
response decode. JSON null falls back to the nested data object.
- Treat a non-success code as a query error instead of an unpaid order,
and require an echoed out_trade_no to match the queried order.
- Strip the request URL (which carries the merchant key) from transport
errors before they are returned.
Notifications:
- Add payment.NotificationConfirmer. After a notification is verified, the
webhook asks the same provider instance to confirm it; EasyPay queries
the upstream order and requires it to be paid with a matching amount and
trade_no (a missing notify trade_no is filled from the query). Failures
are recorded as PAYMENT_UPSTREAM_UNCONFIRMED and answered with 502 so the
gateway retries; the order stays pending and reconcile still applies.
- EasyPay notifications must carry the instance's configured pid, and
field values other than name must be plain tokens.
- Return URLs must use a plain hostname or IP literal without userinfo.
228 lines
7.8 KiB
Go
228 lines
7.8 KiB
Go
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)
|
||
}
|
||
|
||
verifiedProvider, 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
|
||
}
|
||
|
||
resolvedProviderKey := verifiedProvider.ProviderKey()
|
||
|
||
// nil notification means irrelevant event (e.g. Stripe non-payment event); return success.
|
||
if notification == nil {
|
||
writeSuccessResponse(c, resolvedProviderKey)
|
||
return
|
||
}
|
||
|
||
if err := h.paymentService.ConfirmNotificationUpstream(c.Request.Context(), verifiedProvider, notification); err != nil {
|
||
slog.Error("[Payment Webhook] upstream confirmation failed", "provider", resolvedProviderKey, "outTradeNo", notification.OrderID, "tradeNo", notification.TradeNo, "error", err)
|
||
c.String(http.StatusBadGateway, "confirm failed")
|
||
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 ""
|
||
}
|
||
|
||
// verifyNotificationWithProviders returns the provider instance that verified
|
||
// the notification so later steps (upstream confirmation) use the same
|
||
// merchant configuration.
|
||
func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (payment.Provider, *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, notification, nil
|
||
}
|
||
if lastErr != nil {
|
||
return nil, nil, lastErr
|
||
}
|
||
return nil, 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")
|
||
}
|
||
}
|