mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user