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[]`
This commit is contained in:
erio
2026-04-08 02:39:47 +08:00
parent 36b14b0584
commit c016bdba13
10 changed files with 80 additions and 58 deletions
@@ -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"
+9 -9
View File
@@ -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
+6 -6
View File
@@ -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 {
+8 -8
View File
@@ -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{
+9 -9
View File
@@ -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
}
+25 -8
View File
@@ -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
}
@@ -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.
@@ -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)
+4 -4
View File
@@ -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<SubscriptionPlan, 'features'> & { 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
}
+3 -3
View File
@@ -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 }
})
</script>