mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(payment): critical fixes, constants, type safety, and order recovery
Backend: - Fix order physical deletion → status update to FAILED - Fix sync.Once race condition → mutex + bool pattern - Fix encryption key error silently ignored in wire.go - Fix ProviderInstanceResponse missing payment_mode field - Fix fullyDecodeURL infinite loop → single decode - Fix io.ReadAll without size limit → LimitReader - Fix webhook log exposing full rawBody → truncate + debug level - Recover cancelled/expired orders on webhook payment success Frontend: - Extract METHOD_ORDER to providerConfig.ts, remove duplicates - Add PAYMENT_MODE_REDIRECT/API constants, use in all components - Fix any types → unknown in StripePaymentView, PaymentView - Fix hardcoded English text → i18n keys - Fix Vue Router query as string → String() - Fix METHOD_ICONS.easypay reference to non-existent key
This commit is contained in:
@@ -83,7 +83,12 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
|
||||
|
||||
notification, err := provider.VerifyNotification(c.Request.Context(), rawBody, headers)
|
||||
if err != nil {
|
||||
slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err, "method", c.Request.Method, "rawBody", rawBody)
|
||||
truncatedBody := rawBody
|
||||
if len(truncatedBody) > 200 {
|
||||
truncatedBody = truncatedBody[:200] + "...(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
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ import (
|
||||
|
||||
// EasyPay constants.
|
||||
const (
|
||||
easypayCodeSuccess = 1
|
||||
easypayStatusPaid = 1
|
||||
easypayHTTPTimeout = 10 * time.Second
|
||||
easypayCodeSuccess = 1
|
||||
easypayStatusPaid = 1
|
||||
easypayHTTPTimeout = 10 * time.Second
|
||||
maxEasypayResponseSize = 1 << 20 // 1MB
|
||||
)
|
||||
|
||||
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
|
||||
@@ -169,7 +170,7 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
|
||||
}
|
||||
params := make(map[string]string)
|
||||
for k := range values {
|
||||
params[k] = fullyDecodeURL(values.Get(k))
|
||||
params[k] = decodeURLValue(values.Get(k))
|
||||
}
|
||||
sign := params["sign"]
|
||||
if sign == "" {
|
||||
@@ -239,7 +240,7 @@ func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]s
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return io.ReadAll(resp.Body)
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize))
|
||||
}
|
||||
|
||||
func easyPaySign(params map[string]string, pkey string) string {
|
||||
@@ -267,14 +268,11 @@ func easyPayVerifySign(params map[string]string, pkey string, sign string) bool
|
||||
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
|
||||
}
|
||||
|
||||
// fullyDecodeURL repeatedly URL-decodes a string until stable.
|
||||
// Handles double (or multi) encoding caused by upstream proxies.
|
||||
func fullyDecodeURL(s string) string {
|
||||
for {
|
||||
decoded, err := url.QueryUnescape(s)
|
||||
if err != nil || decoded == s {
|
||||
return s
|
||||
}
|
||||
s = decoded
|
||||
// decodeURLValue URL-decodes a string once.
|
||||
func decodeURLValue(s string) string {
|
||||
decoded, err := url.QueryUnescape(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package payment
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -14,7 +15,18 @@ type EncryptionKey []byte
|
||||
|
||||
// ProvideEncryptionKey derives the payment encryption key from the TOTP encryption key in config.
|
||||
func ProvideEncryptionKey(cfg *config.Config) EncryptionKey {
|
||||
key, _ := hex.DecodeString(cfg.Totp.EncryptionKey)
|
||||
if cfg.Totp.EncryptionKey == "" {
|
||||
return nil
|
||||
}
|
||||
key, err := hex.DecodeString(cfg.Totp.EncryptionKey)
|
||||
if err != nil {
|
||||
slog.Error("invalid payment encryption key", "error", err)
|
||||
return nil
|
||||
}
|
||||
if len(key) != 32 {
|
||||
slog.Error("payment encryption key must be 32 bytes", "got", len(key))
|
||||
return nil
|
||||
}
|
||||
return EncryptionKey(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -343,6 +343,7 @@ type ProviderInstanceResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RefundEnabled bool `json:"refund_enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
PaymentMode string `json:"payment_mode"`
|
||||
}
|
||||
|
||||
// ListProviderInstancesWithConfig returns provider instances with decrypted
|
||||
@@ -359,6 +360,7 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte
|
||||
ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
|
||||
SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
|
||||
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, SortOrder: inst.SortOrder,
|
||||
PaymentMode: inst.PaymentMode,
|
||||
}
|
||||
resp.Config = s.decryptAndMaskConfig(inst.Config)
|
||||
result = append(result, resp)
|
||||
|
||||
@@ -147,7 +147,7 @@ type TopUserStat struct {
|
||||
|
||||
type PaymentService struct {
|
||||
providerMu sync.Mutex
|
||||
providerOnce sync.Once
|
||||
providersLoaded bool
|
||||
entClient *dbent.Client
|
||||
registry *payment.Registry
|
||||
loadBalancer payment.LoadBalancer
|
||||
@@ -200,7 +200,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
||||
}
|
||||
resp, err := s.invokeProvider(ctx, order, req, cfg, payAmountStr, payAmount, plan)
|
||||
if err != nil {
|
||||
_ = s.entClient.PaymentOrder.DeleteOneID(order.ID).Exec(ctx)
|
||||
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
||||
SetStatus(OrderStatusFailed).
|
||||
Save(ctx)
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
@@ -566,15 +568,40 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
|
||||
}
|
||||
|
||||
func (s *PaymentService) toPaid(ctx context.Context, o *dbent.PaymentOrder, tradeNo string, paid float64, pk string) error {
|
||||
previousStatus := o.Status
|
||||
now := time.Now()
|
||||
grace := now.Add(-paymentGraceMinutes * time.Minute)
|
||||
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.Or(paymentorder.StatusEQ(OrderStatusPending), paymentorder.And(paymentorder.StatusEQ(OrderStatusExpired), paymentorder.UpdatedAtGTE(grace)))).SetStatus(OrderStatusPaid).SetPayAmount(paid).SetPaymentTradeNo(tradeNo).SetPaidAt(now).ClearFailedAt().ClearFailedReason().Save(ctx)
|
||||
c, err := s.entClient.PaymentOrder.Update().Where(
|
||||
paymentorder.IDEQ(o.ID),
|
||||
paymentorder.Or(
|
||||
paymentorder.StatusEQ(OrderStatusPending),
|
||||
paymentorder.StatusEQ(OrderStatusCancelled),
|
||||
paymentorder.And(
|
||||
paymentorder.StatusEQ(OrderStatusExpired),
|
||||
paymentorder.UpdatedAtGTE(grace),
|
||||
),
|
||||
),
|
||||
).SetStatus(OrderStatusPaid).SetPayAmount(paid).SetPaymentTradeNo(tradeNo).SetPaidAt(now).ClearFailedAt().ClearFailedReason().Save(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update to PAID: %w", err)
|
||||
}
|
||||
if c == 0 {
|
||||
return s.alreadyProcessed(ctx, o)
|
||||
}
|
||||
if previousStatus == OrderStatusCancelled || previousStatus == OrderStatusExpired {
|
||||
slog.Info("order recovered from webhook payment success",
|
||||
"orderID", o.ID,
|
||||
"previousStatus", previousStatus,
|
||||
"tradeNo", tradeNo,
|
||||
"provider", pk,
|
||||
)
|
||||
s.writeAuditLog(ctx, o.ID, "ORDER_RECOVERED", pk, map[string]any{
|
||||
"previous_status": previousStatus,
|
||||
"tradeNo": tradeNo,
|
||||
"paidAmount": paid,
|
||||
"reason": "webhook payment success received after order " + previousStatus,
|
||||
})
|
||||
}
|
||||
s.writeAuditLog(ctx, o.ID, "ORDER_PAID", pk, map[string]any{"tradeNo": tradeNo, "paidAmount": paid})
|
||||
return s.executeFulfillment(ctx, o.ID)
|
||||
}
|
||||
@@ -591,6 +618,18 @@ func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentO
|
||||
return s.executeFulfillment(ctx, o.ID)
|
||||
case OrderStatusPaid, OrderStatusRecharging:
|
||||
return fmt.Errorf("order %d is being processed", o.ID)
|
||||
case OrderStatusExpired:
|
||||
slog.Warn("webhook payment success for expired order beyond grace period",
|
||||
"orderID", o.ID,
|
||||
"status", cur.Status,
|
||||
"updatedAt", cur.UpdatedAt,
|
||||
)
|
||||
s.writeAuditLog(ctx, o.ID, "PAYMENT_AFTER_EXPIRY", "system", map[string]any{
|
||||
"status": cur.Status,
|
||||
"updatedAt": cur.UpdatedAt,
|
||||
"reason": "payment arrived after expiry grace period",
|
||||
})
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -1174,9 +1213,12 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or
|
||||
// It queries all enabled PaymentProviderInstance records, decrypts their config,
|
||||
// creates providers via provider.CreateProvider, and registers them.
|
||||
func (s *PaymentService) EnsureProviders(ctx context.Context) {
|
||||
s.providerOnce.Do(func() {
|
||||
s.providerMu.Lock()
|
||||
defer s.providerMu.Unlock()
|
||||
if !s.providersLoaded {
|
||||
s.loadProviders(ctx)
|
||||
})
|
||||
s.providersLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshProviders clears and re-registers all providers from the database.
|
||||
@@ -1186,8 +1228,7 @@ func (s *PaymentService) RefreshProviders(ctx context.Context) {
|
||||
defer s.providerMu.Unlock()
|
||||
s.registry.Clear()
|
||||
s.loadProviders(ctx)
|
||||
s.providerOnce = sync.Once{} // reset so next EnsureProviders is a no-op until next Refresh
|
||||
s.providerOnce.Do(func() {}) // mark as done since we just loaded
|
||||
s.providersLoaded = true
|
||||
}
|
||||
|
||||
func (s *PaymentService) loadProviders(ctx context.Context) {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { METHOD_ORDER } from './providerConfig'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
import stripeIcon from '@/assets/icons/stripe.svg'
|
||||
@@ -60,9 +61,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Fixed display order for payment methods */
|
||||
const METHOD_ORDER = ['alipay', 'wxpay', 'stripe']
|
||||
|
||||
const METHOD_ICONS: Record<string, string> = {
|
||||
alipay: alipayIcon,
|
||||
wxpay: wxpayIcon,
|
||||
@@ -70,9 +68,10 @@ const METHOD_ICONS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const sortedMethods = computed(() => {
|
||||
const order: readonly string[] = METHOD_ORDER
|
||||
return [...props.methods].sort((a, b) => {
|
||||
const ai = METHOD_ORDER.indexOf(a.type)
|
||||
const bi = METHOD_ORDER.indexOf(b.type)
|
||||
const ai = order.indexOf(a.type)
|
||||
const bi = order.indexOf(b.type)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
})
|
||||
@@ -80,7 +79,7 @@ const sortedMethods = computed(() => {
|
||||
function methodIcon(type: string): string {
|
||||
if (type.includes('alipay')) return METHOD_ICONS.alipay
|
||||
if (type.includes('wxpay')) return METHOD_ICONS.wxpay
|
||||
return METHOD_ICONS[type] || METHOD_ICONS.easypay
|
||||
return METHOD_ICONS[type] || alipayIcon
|
||||
}
|
||||
|
||||
function methodSelectedClass(type: string): string {
|
||||
|
||||
@@ -231,6 +231,8 @@ import {
|
||||
PROVIDER_SUPPORTED_TYPES,
|
||||
PROVIDER_CALLBACK_PATHS,
|
||||
WEBHOOK_PATHS,
|
||||
PAYMENT_MODE_REDIRECT,
|
||||
PAYMENT_MODE_API,
|
||||
getAvailableTypes,
|
||||
extractBaseUrl,
|
||||
} from './providerConfig'
|
||||
@@ -267,7 +269,7 @@ const form = reactive({
|
||||
provider_key: 'easypay',
|
||||
supported_types: [] as string[],
|
||||
enabled: true,
|
||||
payment_mode: 'api',
|
||||
payment_mode: PAYMENT_MODE_API,
|
||||
refund_enabled: false,
|
||||
})
|
||||
const config = reactive<Record<string, string>>({})
|
||||
@@ -287,8 +289,8 @@ const stripeWebhookUrl = computed(() =>
|
||||
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
|
||||
|
||||
const paymentModeOptions = computed(() => [
|
||||
{ value: 'redirect', label: t('admin.settings.payment.modeRedirect') },
|
||||
{ value: 'api', label: t('admin.settings.payment.modeQRCode') },
|
||||
{ value: PAYMENT_MODE_REDIRECT, label: t('admin.settings.payment.modeRedirect') },
|
||||
{ value: PAYMENT_MODE_API, label: t('admin.settings.payment.modeQRCode') },
|
||||
])
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
@@ -454,7 +456,7 @@ function reset(defaultKey: string) {
|
||||
form.provider_key = defaultKey
|
||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
|
||||
form.enabled = true
|
||||
form.payment_mode = defaultKey === 'easypay' ? 'api' : ''
|
||||
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_API : ''
|
||||
form.refund_enabled = false
|
||||
clearConfig()
|
||||
applyDefaults()
|
||||
@@ -465,7 +467,7 @@ function loadProvider(provider: ProviderInstance) {
|
||||
form.provider_key = provider.provider_key
|
||||
form.supported_types = provider.supported_types
|
||||
form.enabled = provider.enabled
|
||||
form.payment_mode = provider.payment_mode || 'api'
|
||||
form.payment_mode = provider.payment_mode || PAYMENT_MODE_API
|
||||
form.refund_enabled = provider.refund_enabled
|
||||
clearConfig()
|
||||
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
|
||||
|
||||
@@ -68,6 +68,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import { PAYMENT_MODE_REDIRECT, PAYMENT_MODE_API } from './providerConfig'
|
||||
|
||||
const PROVIDER_KEY_LABELS: Record<string, string> = {
|
||||
easypay: 'admin.settings.payment.providerEasypay',
|
||||
@@ -94,8 +95,8 @@ const { t } = useI18n()
|
||||
const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_key] || props.provider.provider_key))
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (props.provider.payment_mode === 'redirect') return t('admin.settings.payment.modeRedirect')
|
||||
if (props.provider.payment_mode === 'api') return t('admin.settings.payment.modeQRCode')
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_REDIRECT) return t('admin.settings.payment.modeRedirect')
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_API) return t('admin.settings.payment.modeQRCode')
|
||||
return ''
|
||||
})
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
|
||||
/** Available payment modes for EasyPay providers. */
|
||||
export const EASYPAY_PAYMENT_MODES = ['redirect', 'api'] as const
|
||||
|
||||
/** Fixed display order for user-facing payment methods */
|
||||
export const METHOD_ORDER = ['alipay', 'wxpay', 'stripe'] as const
|
||||
|
||||
/** Payment mode constants */
|
||||
export const PAYMENT_MODE_REDIRECT = 'redirect'
|
||||
export const PAYMENT_MODE_API = 'api'
|
||||
|
||||
/** Webhook paths for each provider (relative to origin). */
|
||||
export const WEBHOOK_PATHS: Record<string, string> = {
|
||||
easypay: '/api/v1/payment/webhook/easypay',
|
||||
|
||||
@@ -5146,6 +5146,8 @@ export default {
|
||||
refundReason: 'Refund Reason',
|
||||
refundReasonPlaceholder: 'Please describe your refund reason',
|
||||
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
|
||||
stripeMissingParams: 'Missing order ID or client secret',
|
||||
stripeNotConfigured: 'Stripe is not configured',
|
||||
errors: {
|
||||
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
cancelRateLimited: 'Too many cancellations. Please try again later.',
|
||||
|
||||
@@ -5343,6 +5343,8 @@ export default {
|
||||
refundReason: '退款原因',
|
||||
refundReasonPlaceholder: '请描述您的退款原因',
|
||||
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
|
||||
stripeMissingParams: '缺少订单ID或支付密钥',
|
||||
stripeNotConfigured: 'Stripe 未配置',
|
||||
errors: {
|
||||
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
cancelRateLimited: '取消订单过于频繁,请稍后再试',
|
||||
|
||||
@@ -182,12 +182,12 @@ watch(qrUrl, () => renderQR())
|
||||
|
||||
onMounted(() => {
|
||||
orderId.value = Number(route.query.order_id) || 0
|
||||
qrUrl.value = (route.query.qr as string) || ''
|
||||
payUrl.value = (route.query.pay_url as string) || ''
|
||||
paymentType.value = (route.query.payment_type as string) || ''
|
||||
qrUrl.value = String(route.query.qr || '')
|
||||
payUrl.value = String(route.query.pay_url || '')
|
||||
paymentType.value = String(route.query.payment_type || '')
|
||||
|
||||
// Calculate countdown from expiresAt
|
||||
const expiresAtStr = route.query.expires_at as string
|
||||
const expiresAtStr = String(route.query.expires_at || '')
|
||||
let seconds = 30 * 60 // fallback: 30 minutes
|
||||
if (expiresAtStr) {
|
||||
const expiresAt = new Date(expiresAtStr)
|
||||
|
||||
@@ -120,14 +120,14 @@ onMounted(async () => {
|
||||
|
||||
// Fallback: EasyPay return URL with out_trade_no
|
||||
if (!orderId && route.query.out_trade_no) {
|
||||
const outTradeNo = route.query.out_trade_no as string
|
||||
const outTradeNo = String(route.query.out_trade_no || '')
|
||||
orderId = parseOutTradeNo(outTradeNo)
|
||||
// Store return info for display when order lookup fails
|
||||
returnInfo.value = {
|
||||
outTradeNo,
|
||||
money: (route.query.money as string) || '',
|
||||
type: (route.query.type as string) || '',
|
||||
tradeStatus: (route.query.trade_status as string) || '',
|
||||
money: String(route.query.money || ''),
|
||||
type: String(route.query.type || ''),
|
||||
tradeStatus: String(route.query.trade_status || ''),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { METHOD_ORDER } from '@/components/payment/providerConfig'
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
|
||||
@@ -241,10 +242,12 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
errorMessage.value = t('payment.result.failed')
|
||||
appStore.showError(errorMessage.value)
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.reason === 'TOO_MANY_PENDING') {
|
||||
errorMessage.value = t('payment.errors.tooManyPending', { max: err.metadata?.max || '' })
|
||||
} else if (err.reason === 'CANCEL_RATE_LIMITED') {
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as Record<string, unknown>
|
||||
if (apiErr.reason === 'TOO_MANY_PENDING') {
|
||||
const metadata = apiErr.metadata as Record<string, unknown> | undefined
|
||||
errorMessage.value = t('payment.errors.tooManyPending', { max: metadata?.max || '' })
|
||||
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
|
||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||
} else {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
@@ -273,10 +276,10 @@ onMounted(async () => {
|
||||
methodLimits.value = limitsRes.data
|
||||
} catch (e) { /* limits endpoint may not exist */ }
|
||||
if (enabledMethods.value.length) {
|
||||
const METHOD_ORDER = ['alipay', 'wxpay', 'stripe']
|
||||
const order: readonly string[] = METHOD_ORDER
|
||||
const sorted = [...enabledMethods.value].sort((a, b) => {
|
||||
const ai = METHOD_ORDER.indexOf(a)
|
||||
const bi = METHOD_ORDER.indexOf(b)
|
||||
const ai = order.indexOf(a)
|
||||
const bi = order.indexOf(b)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
selectedMethod.value = sorted[0]
|
||||
|
||||
@@ -50,6 +50,7 @@ import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
@@ -66,17 +67,17 @@ const stripeSuccess = ref(false)
|
||||
const stripeReady = ref(false)
|
||||
const order = ref<PaymentOrder | null>(null)
|
||||
|
||||
let stripeInstance: any = null
|
||||
let stripeInstance: Stripe | null = null
|
||||
let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elementsInstance: any = null
|
||||
let elementsInstance: StripeElements | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const orderId = Number(route.query.order_id)
|
||||
const clientSecret = route.query.client_secret as string
|
||||
const clientSecret = String(route.query.client_secret || '')
|
||||
|
||||
if (!orderId || !clientSecret) {
|
||||
loading.value = false
|
||||
initError.value = 'Missing order ID or client secret'
|
||||
initError.value = t('payment.stripeMissingParams')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ onMounted(async () => {
|
||||
|
||||
await paymentStore.fetchConfig()
|
||||
const publishableKey = paymentStore.config?.stripe_publishable_key
|
||||
if (!publishableKey) { initError.value = 'Stripe is not configured'; return }
|
||||
if (!publishableKey) { initError.value = t('payment.stripeNotConfigured'); return }
|
||||
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(publishableKey)
|
||||
@@ -107,7 +108,7 @@ onMounted(async () => {
|
||||
} as Record<string, unknown>)
|
||||
paymentElement.mount('#stripe-payment-element')
|
||||
paymentElement.on('ready', () => { stripeReady.value = true })
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -135,10 +136,10 @@ async function handlePay() {
|
||||
} else {
|
||||
stripeSuccess.value = true
|
||||
redirectTimer = setTimeout(() => {
|
||||
router.push({ path: '/payment/result', query: { order_id: route.query.order_id as string, status: 'success' } })
|
||||
router.push({ path: '/payment/result', query: { order_id: String(route.query.order_id || ''), status: 'success' } })
|
||||
}, 2000)
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
stripeError.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
} finally {
|
||||
stripeSubmitting.value = false
|
||||
|
||||
Reference in New Issue
Block a user