From c016bdba136d1f7d93c425fd2ca25c69abbe9de1 Mon Sep 17 00:00:00 2001 From: erio Date: Wed, 8 Apr 2026 02:39:47 +0800 Subject: [PATCH] refactor(payment): replace magic strings with constants, fix catch types Backend: - Add ProviderStatus* constants (pending/paid/success/failed/refunded) - Add DefaultLoadBalanceStrategy, ConfigKeyPublishableKey constants - GetBasePaymentType: use Type* constants instead of raw strings - Webhook handler: use payment.Type* for provider keys, extract webhookLogTruncateLen constant - All 4 providers: replace status string literals with ProviderStatus* Frontend: - All catch blocks: add `: unknown` type annotation - stores/payment.ts: replace `any` with proper type for plan parsing - PaymentProviderDialog: replace `as any` with `as SelectOption[]` --- .../handler/payment_webhook_handler.go | 17 ++++++---- backend/internal/payment/provider/alipay.go | 18 +++++----- backend/internal/payment/provider/easypay.go | 12 +++---- backend/internal/payment/provider/stripe.go | 16 ++++----- backend/internal/payment/provider/wxpay.go | 18 +++++----- backend/internal/payment/types.go | 33 ++++++++++++++----- .../service/payment_config_service.go | 4 +-- .../service/payment_config_service_test.go | 6 ++-- frontend/src/stores/payment.ts | 8 ++--- frontend/src/views/user/PaymentView.vue | 6 ++-- 10 files changed, 80 insertions(+), 58 deletions(-) diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index 6b6f129e46..fa4b176fa6 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -21,6 +21,9 @@ type PaymentWebhookHandler struct { // 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{ @@ -32,25 +35,25 @@ func NewPaymentWebhookHandler(paymentService *service.PaymentService, registry * // EasyPayNotify handles EasyPay payment notifications. // POST /api/v1/payment/webhook/easypay func (h *PaymentWebhookHandler) EasyPayNotify(c *gin.Context) { - h.handleNotify(c, "easypay") + 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, "alipay") + 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, "wxpay") + 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, "stripe") + h.handleNotify(c, payment.TypeStripe) } // handleNotify is the shared logic for all provider webhook handlers. @@ -84,8 +87,8 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) notification, err := provider.VerifyNotification(c.Request.Context(), rawBody, headers) if err != nil { truncatedBody := rawBody - if len(truncatedBody) > 200 { - truncatedBody = truncatedBody[:200] + "...(truncated)" + 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) @@ -111,7 +114,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) // successResponse returns the provider-specific success response string. func successResponse(providerKey string) string { switch providerKey { - case "stripe": + case payment.TypeStripe: return "" default: return "success" diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index 6ce599f275..28e02c2a4b 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -66,7 +66,7 @@ func (a *Alipay) getClient() (*alipay.Client, error) { } func (a *Alipay) Name() string { return "Alipay" } -func (a *Alipay) ProviderKey() string { return "alipay" } +func (a *Alipay) ProviderKey() string { return payment.TypeAlipay } func (a *Alipay) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeAlipayDirect} } @@ -144,18 +144,18 @@ func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query if isTradeNotExist(err) { return &payment.QueryOrderResponse{ TradeNo: tradeNo, - Status: "pending", + Status: payment.ProviderStatusPending, }, nil } return nil, fmt.Errorf("alipay TradeQuery: %w", err) } - status := "pending" + status := payment.ProviderStatusPending switch result.TradeStatus { case alipay.TradeStatusSuccess, alipay.TradeStatusFinished: - status = "paid" + status = payment.ProviderStatusPaid case alipay.TradeStatusClosed: - status = "failed" + status = payment.ProviderStatusFailed } amount, err := strconv.ParseFloat(result.TotalAmount, 64) @@ -188,9 +188,9 @@ func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[s return nil, fmt.Errorf("alipay verify notification: %w", err) } - status := "failed" + status := payment.ProviderStatusFailed if notification.TradeStatus == alipay.TradeStatusSuccess || notification.TradeStatus == alipay.TradeStatusFinished { - status = "success" + status = payment.ProviderStatusSuccess } amount, err := strconv.ParseFloat(notification.TotalAmount, 64) @@ -224,9 +224,9 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen return nil, fmt.Errorf("alipay TradeRefund: %w", err) } - refundStatus := "pending" + refundStatus := payment.ProviderStatusPending if result.FundChange == "Y" { - refundStatus = "success" + refundStatus = payment.ProviderStatusSuccess } refundID := result.TradeNo diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 973a664084..46150856f3 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -50,7 +50,7 @@ func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) { } func (e *EasyPay) Name() string { return "EasyPay" } -func (e *EasyPay) ProviderKey() string { return "easypay" } +func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay } func (e *EasyPay) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay} } @@ -156,9 +156,9 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("easypay parse query: %w", err) } - status := "pending" + status := payment.ProviderStatusPending if resp.Status == easypayStatusPaid { - status = "paid" + status = payment.ProviderStatusPaid } return &payment.QueryOrderResponse{TradeNo: tradeNo, Status: status}, nil } @@ -179,9 +179,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st if !easyPayVerifySign(params, e.config["pkey"], sign) { return nil, fmt.Errorf("invalid signature") } - status := "failed" + status := payment.ProviderStatusFailed if params["trade_status"] == "TRADE_SUCCESS" { - status = "success" + status = payment.ProviderStatusSuccess } amount, _ := strconv.ParseFloat(params["money"], 64) return &payment.PaymentNotification{ @@ -209,7 +209,7 @@ func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payme if resp.Code != easypayCodeSuccess { return nil, fmt.Errorf("easypay refund failed: %s", resp.Msg) } - return &payment.RefundResponse{RefundID: req.TradeNo, Status: "success"}, nil + return &payment.RefundResponse{RefundID: req.TradeNo, Status: payment.ProviderStatusSuccess}, nil } func (e *EasyPay) resolveCID(paymentType string) string { diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 4e43cef685..15fe6ba90e 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -58,7 +58,7 @@ func (s *Stripe) GetPublishableKey() string { } func (s *Stripe) Name() string { return "Stripe" } -func (s *Stripe) ProviderKey() string { return "stripe" } +func (s *Stripe) ProviderKey() string { return payment.TypeStripe } func (s *Stripe) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeStripe} } @@ -142,12 +142,12 @@ func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query return nil, fmt.Errorf("stripe query order: %w", err) } - status := "pending" + status := payment.ProviderStatusPending switch pi.Status { case stripe.PaymentIntentStatusSucceeded: - status = "paid" + status = payment.ProviderStatusPaid case stripe.PaymentIntentStatusCanceled: - status = "failed" + status = payment.ProviderStatusFailed } return &payment.QueryOrderResponse{ @@ -178,9 +178,9 @@ func (s *Stripe) VerifyNotification(_ context.Context, rawBody string, headers m switch event.Type { case stripeEventPaymentSuccess: - return parseStripePaymentIntent(&event, "success", rawBody) + return parseStripePaymentIntent(&event, payment.ProviderStatusSuccess, rawBody) case stripeEventPaymentFailed: - return parseStripePaymentIntent(&event, "failed", rawBody) + return parseStripePaymentIntent(&event, payment.ProviderStatusFailed, rawBody) } return nil, nil @@ -221,9 +221,9 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen return nil, fmt.Errorf("stripe refund: %w", err) } - refundStatus := "pending" + refundStatus := payment.ProviderStatusPending if r.Status == stripe.RefundStatusSucceeded { - refundStatus = "success" + refundStatus = payment.ProviderStatusSuccess } return &payment.RefundResponse{ diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 96b252c35e..5448aba3c3 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -54,7 +54,7 @@ func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) { } func (w *Wxpay) Name() string { return "Wxpay" } -func (w *Wxpay) ProviderKey() string { return "wxpay" } +func (w *Wxpay) ProviderKey() string { return payment.TypeWxpay } func (w *Wxpay) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeWxpayDirect} } @@ -202,13 +202,13 @@ func wxSV(s *string) string { func mapWxState(s string) string { switch s { case "SUCCESS": - return "paid" + return payment.ProviderStatusPaid case "REFUND": - return "refunded" + return payment.ProviderStatusRefunded case "CLOSED", "PAYERROR": - return "failed" + return payment.ProviderStatusFailed default: - return "pending" + return payment.ProviderStatusPending } } @@ -262,9 +262,9 @@ func (w *Wxpay) VerifyNotification(ctx context.Context, rawBody string, headers if tx.Amount != nil && tx.Amount.Total != nil { amt = float64(*tx.Amount.Total) / wxpayFenPerYuan } - st := "failed" + st := payment.ProviderStatusFailed if wxSV(tx.TradeState) == "SUCCESS" { - st = "success" + st = payment.ProviderStatusSuccess } return &payment.PaymentNotification{ TradeNo: wxSV(tx.TransactionId), OrderID: wxSV(tx.OutTradeNo), @@ -300,9 +300,9 @@ func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment if rid == "" { rid = fmt.Sprintf("%s-refund", req.OrderID) } - st := "pending" + st := payment.ProviderStatusPending if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS { - st = "success" + st = payment.ProviderStatusSuccess } return &payment.RefundResponse{RefundID: rid, Status: st}, nil } diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 80849db5a0..26b5edde70 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -35,18 +35,35 @@ const ( OrderStatusRefundFailed = "REFUND_FAILED" ) +// Provider-level status constants returned by provider implementations +// to the service layer (lowercase, distinct from OrderStatus uppercase constants). +const ( + ProviderStatusPending = "pending" + ProviderStatusPaid = "paid" + ProviderStatusSuccess = "success" + ProviderStatusFailed = "failed" + ProviderStatusRefunded = "refunded" +) + +// DefaultLoadBalanceStrategy is the default load-balancing strategy +// used when no strategy is configured. +const DefaultLoadBalanceStrategy = "round-robin" + +// ConfigKeyPublishableKey is the config map key for Stripe's publishable key. +const ConfigKeyPublishableKey = "publishableKey" + // GetBasePaymentType extracts the base payment method from a composite key. // For example, "alipay_direct" -> "alipay". func GetBasePaymentType(t string) string { switch { - case t == "easypay": - return "easypay" - 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 t == TypeEasyPay: + return TypeEasyPay + case t == TypeStripe || t == TypeCard || t == TypeLink: + return TypeStripe + case len(t) >= len(TypeAlipay) && t[:len(TypeAlipay)] == TypeAlipay: + return TypeAlipay + case len(t) >= len(TypeWxpay) && t[:len(TypeWxpay)] == TypeWxpay: + return TypeWxpay default: return t } diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index f6d4556fa9..f56b2a1c48 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -224,7 +224,7 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme CancelRateLimitMode: vals[SettingCancelWindowMode], } if cfg.LoadBalanceStrategy == "" { - cfg.LoadBalanceStrategy = "round-robin" + cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy } if raw := vals[SettingEnabledPaymentTypes]; raw != "" { for _, t := range strings.Split(raw, ",") { @@ -251,7 +251,7 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri if cfg == nil { return "" } - return cfg["publishableKey"] + return cfg[payment.ConfigKeyPublishableKey] } // UpdatePaymentConfig updates the payment configuration settings. diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index fe3d838ad2..cab702651c 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -2,6 +2,8 @@ package service import ( "testing" + + "github.com/Wei-Shaw/sub2api/internal/payment" ) func TestPcParseFloat(t *testing.T) { @@ -84,8 +86,8 @@ func TestParsePaymentConfig(t *testing.T) { if cfg.MaxPendingOrders != 3 { t.Fatalf("expected MaxPendingOrders=3, got %v", cfg.MaxPendingOrders) } - if cfg.LoadBalanceStrategy != "round-robin" { - t.Fatalf("expected LoadBalanceStrategy=round-robin, got %q", cfg.LoadBalanceStrategy) + if cfg.LoadBalanceStrategy != payment.DefaultLoadBalanceStrategy { + t.Fatalf("expected LoadBalanceStrategy=%s, got %q", payment.DefaultLoadBalanceStrategy, cfg.LoadBalanceStrategy) } if len(cfg.EnabledTypes) != 0 { t.Fatalf("expected empty EnabledTypes, got %v", cfg.EnabledTypes) diff --git a/frontend/src/stores/payment.ts b/frontend/src/stores/payment.ts index 9b3a0fa475..ce2d7b3a1e 100644 --- a/frontend/src/stores/payment.ts +++ b/frontend/src/stores/payment.ts @@ -34,7 +34,7 @@ export const usePaymentStore = defineStore('payment', () => { config.value = response.data configLoaded.value = true return config.value - } catch (error) { + } catch (error: unknown) { console.error('[payment] Failed to fetch config:', error) return null } finally { @@ -47,14 +47,14 @@ export const usePaymentStore = defineStore('payment', () => { try { const response = await paymentAPI.getPlans() // Backend returns features as newline-separated string; parse to array - plans.value = (response.data || []).map((p: any) => ({ + plans.value = (response.data || []).map((p: Omit & { features: string | string[] }) => ({ ...p, features: typeof p.features === 'string' ? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean) : (p.features || []), })) return plans.value - } catch (error) { + } catch (error: unknown) { console.error('[payment] Failed to fetch plans:', error) return [] } @@ -75,7 +75,7 @@ export const usePaymentStore = defineStore('payment', () => { currentOrder.value = order } return order - } catch (error) { + } catch (error: unknown) { console.error('[payment] Failed to poll order status:', error) return null } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 464bdd3d03..2629b8514f 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -283,7 +283,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb async function loadPlans() { plansLoading.value = true - try { await paymentStore.fetchPlans() } catch (err) { console.error('Failed to load plans:', err) } + try { await paymentStore.fetchPlans() } catch (err: unknown) { console.error('Failed to load plans:', err) } finally { plansLoading.value = false } } @@ -297,7 +297,7 @@ onMounted(async () => { try { const limitsRes = await paymentAPI.getLimits() limitsData.value = limitsRes.data - } catch (e) { /* limits endpoint may not exist */ } + } catch (e: unknown) { /* limits endpoint may not exist */ } if (enabledMethods.value.length) { const order: readonly string[] = METHOD_ORDER const sorted = [...enabledMethods.value].sort((a, b) => { @@ -311,7 +311,7 @@ onMounted(async () => { activeTab.value = 'subscription' await loadPlans() } - } catch (err) { console.error('Failed to load config:', err) } + } catch (err: unknown) { console.error('Failed to load config:', err) } finally { loading.value = false } })