Backend: - Extract YuanToFen/FenToYuan to payment/amount.go using shopspring/decimal - Require alipay publicKey in config validation - Fix wxpay webhook response to return JSON per V3 spec - Remove wxpay certSerial fallback to publicKeyId - Define magic strings as named constants in wxpay/alipay providers - Add slog warning for wxpay H5→Native payment downgrade - Make EncryptionKey validation return error on invalid (non-empty) key - Make decryptConfig propagate errors instead of returning nil - Add idempotency check in doBalance to prevent stuck FAILED retries Frontend: - Fix dashboard currency symbol from $ to ¥ - Fix AdminPaymentPlansView any type to proper SubscriptionPlan type - Make quick amount buttons follow selected payment method limits - Center help image with larger height and text below
133 lines
4.3 KiB
Go
133 lines
4.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
provider, err := h.registry.GetProviderByKey(providerKey)
|
|
if err != nil {
|
|
slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err)
|
|
writeSuccessResponse(c, providerKey)
|
|
return
|
|
}
|
|
|
|
headers := make(map[string]string)
|
|
for k := range c.Request.Header {
|
|
headers[strings.ToLower(k)] = c.GetHeader(k)
|
|
}
|
|
|
|
notification, err := provider.VerifyNotification(c.Request.Context(), 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, providerKey)
|
|
return
|
|
}
|
|
|
|
if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, providerKey); err != nil {
|
|
slog.Error("[Payment Webhook] handle notification failed", "provider", providerKey, "error", err)
|
|
c.String(http.StatusInternalServerError, "handle failed")
|
|
return
|
|
}
|
|
|
|
writeSuccessResponse(c, providerKey)
|
|
}
|
|
|
|
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
|
|
type wxpaySuccessResponse struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// writeSuccessResponse sends the provider-specific success response.
|
|
// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"};
|
|
// Stripe expects an empty 200; others accept plain text "success".
|
|
func writeSuccessResponse(c *gin.Context, providerKey string) {
|
|
switch providerKey {
|
|
case payment.TypeWxpay:
|
|
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"})
|
|
case payment.TypeStripe:
|
|
c.String(http.StatusOK, "")
|
|
default:
|
|
c.String(http.StatusOK, "success")
|
|
}
|
|
}
|