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