Files
sub2api/backend/internal/handler/payment_webhook_handler.go
shaw 1b92038eaf fix(payment): fix EasyPay order queries and confirm notifications before crediting
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.
2026-10-16 09:39:55 +08:00

228 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}