fix(payment): webhook GET support, Stripe as single method, QR page improvements

- EasyPay webhook: add GET route + read params from URL query (fix 404)
- Stripe: expose as single "stripe" method to users, sub-types (card/link/
  alipay/wxpay) passed to PaymentIntent internally via instance config
- QR code page: use order expiresAt for countdown, add cancel button
- InstanceSelection carries SupportedTypes for provider-specific routing
This commit is contained in:
erio
2026-04-07 10:38:21 +08:00
parent d98b4ffaad
commit 0cca4524c9
11 changed files with 125 additions and 42 deletions
@@ -55,11 +55,18 @@ func (h *PaymentWebhookHandler) StripeWebhook(c *gin.Context) {
// handleNotify is the shared logic for all provider webhook handlers.
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
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
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)
@@ -74,7 +81,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
headers[strings.ToLower(k)] = c.GetHeader(k)
}
notification, err := provider.VerifyNotification(c.Request.Context(), string(body), headers)
notification, err := provider.VerifyNotification(c.Request.Context(), rawBody, headers)
if err != nil {
slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err)
c.String(http.StatusBadRequest, "verify failed")
+3 -2
View File
@@ -82,8 +82,9 @@ func (lb *DefaultLoadBalancer) SelectInstance(ctx context.Context, providerKey s
}
return &InstanceSelection{
InstanceID: fmt.Sprintf("%d", selected.ID),
Config: config,
InstanceID: fmt.Sprintf("%d", selected.ID),
Config: config,
SupportedTypes: selected.SupportedTypes,
}, nil
}
+34 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"math"
"strconv"
"strings"
"sync"
"github.com/Wei-Shaw/sub2api/internal/payment"
@@ -59,7 +60,7 @@ func (s *Stripe) GetPublishableKey() string {
func (s *Stripe) Name() string { return "Stripe" }
func (s *Stripe) ProviderKey() string { return "stripe" }
func (s *Stripe) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeCard, payment.TypeAlipay, payment.TypeWxpay, payment.TypeLink}
return []payment.PaymentType{payment.TypeStripe}
}
// centsToYuan converts an amount in cents (int64) to yuan (float64).
@@ -93,10 +94,8 @@ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentReq
return nil, fmt.Errorf("stripe create payment: %w", err)
}
methods, ok := stripePaymentMethodTypes[req.PaymentType]
if !ok {
methods = []string{"card"}
}
// Collect all Stripe payment_method_types from the instance's configured sub-methods
methods := resolveStripeMethodTypes(req.InstanceSubMethods)
pmTypes := make([]*string, len(methods))
for i, m := range methods {
@@ -112,7 +111,7 @@ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentReq
}
// WeChat Pay requires payment_method_options with client type
if req.PaymentType == payment.TypeWxpay {
if hasStripeMethod(methods, "wechat_pay") {
params.PaymentMethodOptions = &stripe.PaymentIntentCreatePaymentMethodOptionsParams{
WeChatPay: &stripe.PaymentIntentCreatePaymentMethodOptionsWeChatPayParams{
Client: stripe.String("web"),
@@ -233,6 +232,35 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
}, nil
}
// resolveStripeMethodTypes converts instance supported_types (comma-separated)
// into Stripe API payment_method_types. Falls back to ["card"] if empty.
func resolveStripeMethodTypes(instanceSubMethods string) []string {
if instanceSubMethods == "" {
return []string{"card"}
}
var methods []string
for _, t := range strings.Split(instanceSubMethods, ",") {
t = strings.TrimSpace(t)
if mapped, ok := stripePaymentMethodTypes[t]; ok {
methods = append(methods, mapped...)
}
}
if len(methods) == 0 {
return []string{"card"}
}
return methods
}
// hasStripeMethod checks if the given Stripe method list contains the target method.
func hasStripeMethod(methods []string, target string) bool {
for _, m := range methods {
if m == target {
return true
}
}
return false
}
// CancelPayment cancels a pending PaymentIntent.
func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error {
s.ensureInit()
+14 -16
View File
@@ -41,16 +41,12 @@ func GetBasePaymentType(t string) string {
switch {
case t == "easypay":
return "easypay"
case t == "card":
return "card"
case t == "link":
return "link"
case t == "stripe" || t == "card" || t == "link":
return "stripe"
case len(t) >= 6 && t[:6] == "alipay":
return "alipay"
case len(t) >= 5 && t[:5] == "wxpay":
return "wxpay"
case len(t) >= 6 && t[:6] == "stripe":
return "stripe"
default:
return t
}
@@ -58,14 +54,15 @@ func GetBasePaymentType(t string) string {
// CreatePaymentRequest holds the parameters for creating a new payment.
type CreatePaymentRequest struct {
OrderID string // Internal order ID
Amount string // Pay amount in CNY (formatted to 2 decimal places)
PaymentType string // e.g. "alipay", "wxpay"
Subject string // Product description
NotifyURL string // Webhook callback URL
ReturnURL string // Browser redirect URL after payment
ClientIP string // Payer's IP address
IsMobile bool // Whether the request comes from a mobile device
OrderID string // Internal order ID
Amount string // Pay amount in CNY (formatted to 2 decimal places)
PaymentType string // e.g. "alipay", "wxpay", "stripe"
Subject string // Product description
NotifyURL string // Webhook callback URL
ReturnURL string // Browser redirect URL after payment
ClientIP string // Payer's IP address
IsMobile bool // Whether the request comes from a mobile device
InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe)
}
// CreatePaymentResponse is returned after successfully initiating a payment.
@@ -109,8 +106,9 @@ type RefundResponse struct {
// InstanceSelection holds the selected provider instance and its decrypted config.
type InstanceSelection struct {
InstanceID string
Config map[string]string
InstanceID string
Config map[string]string
SupportedTypes string // Comma-separated list of supported payment types from the instance
}
// Provider defines the interface that all payment providers must implement.
@@ -43,6 +43,8 @@ func RegisterPaymentRoutes(
// --- Webhook endpoints (no auth) ---
webhook := v1.Group("/payment/webhook")
{
// EasyPay sends GET callbacks with query params
webhook.GET("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/alipay", webhookHandler.AlipayNotify)
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
@@ -627,9 +627,13 @@ func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.Su
return plan, nil
}
// stripeSubTypes are types that should be aggregated under "stripe" for user-facing display.
var stripeSubTypes = map[string]bool{"card": true, "link": true}
// GetAvailableMethodLimits collects all payment types from enabled provider
// instances and returns limits for each. This is used by the user-facing payment
// page to discover which payment methods are actually available.
// Stripe sub-types (card, link) are aggregated under "stripe".
func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([]MethodLimits, error) {
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
@@ -640,7 +644,12 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([]
typeSet := make(map[string]bool)
for _, inst := range instances {
for _, t := range splitTypes(inst.SupportedTypes) {
typeSet[t] = true
// Stripe sub-types (card, link) are represented as "stripe" to users
if stripeSubTypes[t] || t == "stripe" {
typeSet["stripe"] = true
} else {
typeSet[t] = true
}
}
}
result := make([]MethodLimits, 0, len(typeSet))
@@ -648,6 +657,10 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([]
ml := MethodLimits{PaymentType: pt}
for _, inst := range instances {
if !payment.InstanceSupportsType(inst.SupportedTypes, pt) {
// For "stripe", check if instance supports any stripe sub-type
if pt == "stripe" && inst.ProviderKey == "stripe" {
pcApplyInstanceLimits(inst, pt, &ml)
}
continue
}
pcApplyInstanceLimits(inst, pt, &ml)
+1 -1
View File
@@ -336,7 +336,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
}
subject := s.buildPaymentSubject(plan, payAmountStr, cfg)
pr, err := provider.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: formatOrderID(order.ID), Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile})
pr, err := provider.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: formatOrderID(order.ID), Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes})
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
}
@@ -63,15 +63,14 @@ const emit = defineEmits<{
const { t } = useI18n()
function isStripe(type: string) {
return type === 'stripe' || type === 'card' || type === 'link'
return type === 'stripe'
}
function iconBgClass(type: string): string {
if (type === 'easypay') return 'bg-[#FF6B35]'
if (type.includes('alipay')) return 'bg-[#00AEEF]'
if (type.includes('wxpay')) return 'bg-[#07C160]'
if (type === 'stripe' || type === 'card') return 'bg-[#635bff]'
if (type === 'link') return 'bg-[#00D66F]'
if (type === 'stripe') return 'bg-[#635bff]'
return 'bg-gray-500'
}
@@ -86,8 +85,7 @@ function methodSelectedClass(type: string): string {
if (type === 'easypay') return 'border-[#FF6B35] bg-orange-50 text-gray-900 shadow-sm dark:bg-orange-950 dark:text-gray-100'
if (type.includes('alipay')) return 'border-[#00AEEF] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (type.includes('wxpay')) return 'border-[#07C160] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (type === 'stripe' || type === 'card') return 'border-[#635bff] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
if (type === 'link') return 'border-[#00D66F] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (type === 'stripe') return 'border-[#635bff] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
}
</script>
+1 -1
View File
@@ -18,7 +18,7 @@ export type OrderStatus =
| 'REFUNDED'
| 'REFUND_FAILED'
export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'card' | 'link'
export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay'
export type OrderType = 'balance' | 'subscription'
+39 -3
View File
@@ -7,13 +7,17 @@
</div>
<div v-if="expired" class="text-center">
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
<button class="btn-primary mt-4" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
<button class="btn btn-primary mt-4" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
<div v-else class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
<!-- Cancel button -->
<button v-if="!expired && orderId" class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('common.cancel') }}
</button>
</div>
</AppLayout>
</template>
@@ -24,18 +28,23 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { useAppStore } from '@/stores'
import QRCode from 'qrcode'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const appStore = useAppStore()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const qrUrl = ref('')
const orderId = ref(0)
const remainingSeconds = ref(0)
const expired = ref(false)
const cancelling = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
@@ -67,7 +76,11 @@ async function pollStatus() {
}
function startCountdown(seconds: number) {
remainingSeconds.value = seconds
remainingSeconds.value = Math.max(0, seconds)
if (remainingSeconds.value <= 0) {
expired.value = true
return
}
countdownTimer = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
@@ -77,6 +90,20 @@ function startCountdown(seconds: number) {
}, 1000)
}
async function handleCancel() {
if (!orderId.value || cancelling.value) return
cancelling.value = true
try {
await paymentAPI.cancelOrder(orderId.value)
cleanup()
router.push('/purchase')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
cancelling.value = false
}
}
function cleanup() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
@@ -87,7 +114,16 @@ watch(qrUrl, () => renderQR())
onMounted(() => {
orderId.value = Number(route.query.order_id) || 0
qrUrl.value = (route.query.qr as string) || ''
startCountdown(30 * 60)
// Calculate countdown from expiresAt
const expiresAtStr = route.query.expires_at as string
let seconds = 30 * 60 // fallback: 30 minutes
if (expiresAtStr) {
const expiresAt = new Date(expiresAtStr)
const now = new Date()
seconds = Math.floor((expiresAt.getTime() - now.getTime()) / 1000)
}
startCountdown(seconds)
pollTimer = setInterval(pollStatus, 3000)
renderQR()
})
+1 -1
View File
@@ -233,7 +233,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
if (result.clientSecret) {
router.push({ path: '/payment/stripe', query: { order_id: String(result.orderId), client_secret: result.clientSecret } })
} else if (result.qrCode) {
router.push({ path: '/payment/qrcode', query: { order_id: String(result.orderId), qr: result.qrCode || '', pay_url: result.payUrl || '' } })
router.push({ path: '/payment/qrcode', query: { order_id: String(result.orderId), qr: result.qrCode || '', pay_url: result.payUrl || '', expires_at: result.expiresAt || '' } })
} else if (result.payUrl) {
window.location.href = result.payUrl
} else {