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:
erio
2026-04-08 01:24:41 +08:00
parent e16c17aa82
commit 1ab77a86a5
15 changed files with 134 additions and 59 deletions
@@ -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
}
+12 -14
View File
@@ -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
}
+13 -1
View File
@@ -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)
+48 -7
View File
@@ -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',
+2
View File
@@ -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.',
+2
View File
@@ -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 || ''),
}
}
+10 -7
View File
@@ -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