diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index f3c16f5d7c6..0b61f0a26e7 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -207,6 +207,18 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) { response.Success(c, resp) } +// GetUSDTRate returns the current CNY-per-USDT rate, cached for 60s and sourced +// from CoinGecko. Used by the recharge UI to show users the implied USDT amount. +// GET /api/v1/payment/usdt/rate +func (h *PaymentHandler) GetUSDTRate(c *gin.Context) { + entry, err := defaultUSDTRateCache.Get(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, entry) +} + // CreateOrderRequest is the request body for creating a payment order. type CreateOrderRequest struct { Amount float64 `json:"amount"` diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index dc70f120e76..7513bae93ad 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -67,6 +67,12 @@ func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) { h.handleNotify(c, payment.TypeAirwallex) } +// UsdtNotify handles USDT (EasyPay-protocol crypto gateway) payment notifications. +// GET|POST /api/v1/payment/webhook/usdt +func (h *PaymentWebhookHandler) UsdtNotify(c *gin.Context) { + h.handleNotify(c, payment.TypeUsdt) +} + // handleNotify is the shared logic for all provider webhook handlers. func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) { var rawBody string @@ -148,7 +154,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) // This allows looking up the correct provider instance before verification. func extractOutTradeNo(rawBody, providerKey string) string { switch providerKey { - case payment.TypeEasyPay, payment.TypeAlipay: + case payment.TypeEasyPay, payment.TypeAlipay, payment.TypeUsdt: values, err := url.ParseQuery(rawBody) if err == nil { return values.Get("out_trade_no") diff --git a/backend/internal/handler/usdt_rate_cache.go b/backend/internal/handler/usdt_rate_cache.go new file mode 100644 index 00000000000..197dc319617 --- /dev/null +++ b/backend/internal/handler/usdt_rate_cache.go @@ -0,0 +1,97 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +type usdtRateEntry struct { + CNYPerUSDT float64 `json:"cny_per_usdt"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` +} + +type usdtRateCache struct { + mu sync.RWMutex + ttl time.Duration + entry *usdtRateEntry + sf singleflight.Group +} + +var defaultUSDTRateCache = &usdtRateCache{ttl: 60 * time.Second} + +func (c *usdtRateCache) Get(ctx context.Context) (usdtRateEntry, error) { + c.mu.RLock() + cached := c.entry + c.mu.RUnlock() + if cached != nil && time.Since(cached.UpdatedAt) < c.ttl { + return *cached, nil + } + + v, err, _ := c.sf.Do("usdt_rate", func() (any, error) { + rate, fetchErr := fetchUSDTRateFromCoinGecko(ctx) + if fetchErr != nil { + // Fall back to stale cache if available so the UI keeps working + // during a transient upstream outage. + c.mu.RLock() + stale := c.entry + c.mu.RUnlock() + if stale != nil { + return *stale, nil + } + return usdtRateEntry{}, fetchErr + } + entry := usdtRateEntry{ + CNYPerUSDT: rate, + UpdatedAt: time.Now().UTC(), + Source: "coingecko", + } + c.mu.Lock() + c.entry = &entry + c.mu.Unlock() + return entry, nil + }) + if err != nil { + return usdtRateEntry{}, err + } + entry, ok := v.(usdtRateEntry) + if !ok { + return usdtRateEntry{}, fmt.Errorf("usdt rate cache: unexpected value type %T", v) + } + return entry, nil +} + +func fetchUSDTRateFromCoinGecko(ctx context.Context) (float64, error) { + const endpoint = "https://api.coingecko.com/api/v3/simple/price?ids=tether&vs_currencies=cny" + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, fmt.Errorf("build request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("coingecko request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return 0, fmt.Errorf("coingecko status=%d", resp.StatusCode) + } + var body struct { + Tether struct { + CNY float64 `json:"cny"` + } `json:"tether"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return 0, fmt.Errorf("decode coingecko: %w", err) + } + if body.Tether.CNY <= 0 { + return 0, fmt.Errorf("coingecko returned non-positive rate: %v", body.Tether.CNY) + } + return body.Tether.CNY, nil +} diff --git a/backend/internal/payment/provider/factory.go b/backend/internal/payment/provider/factory.go index cc34d535deb..9be490b7e46 100644 --- a/backend/internal/payment/provider/factory.go +++ b/backend/internal/payment/provider/factory.go @@ -19,6 +19,8 @@ func CreateProvider(providerKey string, instanceID string, config map[string]str return NewStripe(instanceID, config) case payment.TypeAirwallex: return NewAirwallex(instanceID, config) + case payment.TypeUsdt: + return NewUSDT(instanceID, config) default: return nil, fmt.Errorf("unknown provider key: %s", providerKey) } diff --git a/backend/internal/payment/provider/usdt.go b/backend/internal/payment/provider/usdt.go new file mode 100644 index 00000000000..bd45b568a33 --- /dev/null +++ b/backend/internal/payment/provider/usdt.go @@ -0,0 +1,169 @@ +package provider + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/payment" +) + +// USDT constants. +const ( + usdtHTTPTimeout = 10 * time.Second + usdtDefaultTradeType = "usdt.trc20" +) + +// USDT implements payment.Provider for self-hosted crypto gateways that speak +// the EasyPay (彩虹易支付) protocol but settle in stablecoins — e.g. BEpusdt. +// +// Unlike the EasyPay provider (which exposes alipay/wxpay payment types and can +// call mapi.php), this provider exposes a single first-class "usdt" payment type +// and always uses the hosted checkout page (submit.php). The on-chain trade type +// sent to the gateway (default "usdt.trc20") is configurable so other chains or +// stablecoins can be served by additional instances without code changes. +type USDT struct { + instanceID string + config map[string]string + tradeType string +} + +// NewUSDT creates a new USDT provider. +// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, tradeType (optional). +func NewUSDT(instanceID string, config map[string]string) (*USDT, error) { + for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} { + if strings.TrimSpace(config[k]) == "" { + return nil, fmt.Errorf("usdt config missing required key: %s", k) + } + } + cfg := make(map[string]string, len(config)) + for k, v := range config { + cfg[k] = v + } + cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"]) + tradeType := strings.TrimSpace(cfg["tradeType"]) + if tradeType == "" { + tradeType = usdtDefaultTradeType + } + return &USDT{instanceID: instanceID, config: cfg, tradeType: tradeType}, nil +} + +func (u *USDT) Name() string { return "USDT" } +func (u *USDT) ProviderKey() string { return payment.TypeUsdt } +func (u *USDT) SupportedTypes() []payment.PaymentType { + return []payment.PaymentType{payment.TypeUsdt} +} + +func (u *USDT) apiBase() string { + if u == nil { + return "" + } + return normalizeEasyPayAPIBase(u.config["apiBase"]) +} + +func (u *USDT) MerchantIdentityMetadata() map[string]string { + if u == nil { + return nil + } + pid := strings.TrimSpace(u.config["pid"]) + if pid == "" { + return nil + } + return map[string]string{"pid": pid} +} + +// CreatePayment builds a hosted-checkout (submit.php) URL for browser redirect. +// No server-side API call is made — the user is redirected to the gateway's +// hosted cashier. The crypto trade type (e.g. usdt.trc20) is sent natively, so +// the gateway accepts it directly without any type rewriting. +func (u *USDT) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { + notifyURL, returnURL := u.resolveURLs(req) + params := map[string]string{ + "pid": u.config["pid"], + "type": u.tradeType, + "out_trade_no": req.OrderID, + "notify_url": notifyURL, + "return_url": returnURL, + "name": req.Subject, + "money": req.Amount, + } + if req.IsMobile { + params["device"] = deviceMobile + } + params["sign"] = easyPaySign(params, u.config["pkey"]) + params["sign_type"] = signTypeMD5 + + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + payURL := u.apiBase() + "/submit.php?" + q.Encode() + return &payment.CreatePaymentResponse{PayURL: payURL}, nil +} + +func (u *USDT) resolveURLs(req payment.CreatePaymentRequest) (string, string) { + notifyURL := req.NotifyURL + if notifyURL == "" { + notifyURL = u.config["notifyUrl"] + } + returnURL := req.ReturnURL + if returnURL == "" { + returnURL = u.config["returnUrl"] + } + return notifyURL, returnURL +} + +// QueryOrder is best-effort. The hosted gateway confirms payment via the notify +// callback (the source of truth); polling is only a reconciliation safety net. +func (u *USDT) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { + return &payment.QueryOrderResponse{ + TradeNo: tradeNo, + Status: payment.ProviderStatusPending, + Metadata: u.MerchantIdentityMetadata(), + }, nil +} + +// VerifyNotification verifies the gateway's async notify callback (MD5 sign over +// the form params, identical to the EasyPay protocol). +func (u *USDT) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) { + values, err := url.ParseQuery(rawBody) + if err != nil { + return nil, fmt.Errorf("parse notify: %w", err) + } + params := make(map[string]string) + for k := range values { + params[k] = values.Get(k) + } + sign := params["sign"] + if sign == "" { + return nil, fmt.Errorf("missing sign") + } + if !easyPayVerifySign(params, u.config["pkey"], sign) { + return nil, fmt.Errorf("invalid signature") + } + status := payment.ProviderStatusFailed + if params["trade_status"] == tradeStatusSuccess { + status = payment.ProviderStatusSuccess + } + amount, _ := strconv.ParseFloat(params["money"], 64) + + metadata := u.MerchantIdentityMetadata() + if pid := strings.TrimSpace(params["pid"]); pid != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata["pid"] = pid + } + return &payment.PaymentNotification{ + TradeNo: params["trade_no"], OrderID: params["out_trade_no"], + Amount: amount, Status: status, RawData: rawBody, Metadata: metadata, + }, nil +} + +// Refund is not supported for crypto settlements (on-chain refunds are manual). +func (u *USDT) Refund(_ context.Context, _ payment.RefundRequest) (*payment.RefundResponse, error) { + return nil, fmt.Errorf("usdt: refund not supported") +} diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index d27d9696b27..54fc8d101f8 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -18,6 +18,7 @@ const ( TypeLink PaymentType = "link" TypeEasyPay PaymentType = "easypay" TypeAirwallex PaymentType = "airwallex" + TypeUsdt PaymentType = "usdt" ) // Order status constants shared across payment and service layers. @@ -85,6 +86,8 @@ func GetBasePaymentType(t string) string { return TypeEasyPay case t == TypeAirwallex: return TypeAirwallex + case t == TypeUsdt: + return TypeUsdt case t == TypeStripe || t == TypeCard || t == TypeLink: return TypeStripe case len(t) >= len(TypeAlipay) && t[:len(TypeAlipay)] == TypeAlipay: diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index beeae611463..7410e4c4cf7 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -30,6 +30,7 @@ func RegisterPaymentRoutes( authenticated.GET("/plans", paymentHandler.GetPlans) authenticated.GET("/channels", paymentHandler.GetChannels) authenticated.GET("/limits", paymentHandler.GetLimits) + authenticated.GET("/usdt/rate", paymentHandler.GetUSDTRate) orders := authenticated.Group("/orders") { @@ -63,6 +64,9 @@ func RegisterPaymentRoutes( webhook.POST("/wxpay", webhookHandler.WxpayNotify) webhook.POST("/stripe", webhookHandler.StripeWebhook) webhook.POST("/airwallex", webhookHandler.AirwallexWebhook) + // USDT (EasyPay-protocol crypto gateway) sends GET or POST callbacks + webhook.GET("/usdt", webhookHandler.UsdtNotify) + webhook.POST("/usdt", webhookHandler.UsdtNotify) } // --- Admin payment endpoints (admin auth) --- diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index 7e92558568d..ef04882185e 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -115,6 +115,7 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{ payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}}, payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}}, payment.TypeAirwallex: {"apikey": {}, "webhooksecret": {}}, + payment.TypeUsdt: {"pkey": {}}, } // providerPendingOrderProtectedConfigFields lists config keys that cannot be @@ -127,6 +128,7 @@ var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{ payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}}, payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}, "currency": {}}, payment.TypeAirwallex: {"clientid": {}, "apikey": {}, "webhooksecret": {}, "apibase": {}, "accountid": {}, "currency": {}}, + payment.TypeUsdt: {"pkey": {}, "pid": {}, "apibase": {}, "tradetype": {}}, } func isSensitiveProviderConfigField(providerKey, fieldName string) bool { @@ -177,7 +179,7 @@ func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, pla } var validProviderKeys = map[string]bool{ - payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, + payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, payment.TypeAirwallex: true, payment.TypeUsdt: true, } func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 83edb9e163b..6e99837fa09 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -289,7 +289,7 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat snapshot["merchant_app_id"] = merchantAppID } } - if providerKey == payment.TypeEasyPay { + if providerKey == payment.TypeEasyPay || providerKey == payment.TypeUsdt { if merchantID := strings.TrimSpace(sel.Config["pid"]); merchantID != "" { snapshot["merchant_id"] = merchantID } diff --git a/backend/internal/service/payment_order_provider_snapshot.go b/backend/internal/service/payment_order_provider_snapshot.go index c5d8f86ff50..3325c1b8151 100644 --- a/backend/internal/service/payment_order_provider_snapshot.go +++ b/backend/internal/service/payment_order_provider_snapshot.go @@ -188,6 +188,16 @@ func validateProviderSnapshotMetadata(order *dbent.PaymentOrder, providerKey str return fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual) } } + case payment.TypeUsdt: + if expected := strings.TrimSpace(snapshot.MerchantID); expected != "" { + actual := strings.TrimSpace(metadata["pid"]) + if actual == "" { + return fmt.Errorf("usdt pid missing") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("usdt pid mismatch: expected %s, got %s", expected, actual) + } + } case payment.TypeStripe: if expected := strings.TrimSpace(snapshot.Currency); expected != "" { actual := strings.ToUpper(strings.TrimSpace(metadata["currency"])) diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 92b0ec90c6a..b936419c69c 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -12,7 +12,8 @@ import type { CheckoutInfoResponse, CreateOrderRequest, CreateOrderResult, - PaymentOrder + PaymentOrder, + UsdtRateResponse } from '@/types/payment' import type { BasePaginationResponse } from '@/types' @@ -42,6 +43,11 @@ export const paymentAPI = { return apiClient.get('/payment/limits') }, + /** Get current CNY-per-USDT exchange rate (cached 60s server-side) */ + getUsdtRate() { + return apiClient.get('/payment/usdt/rate') + }, + /** Create a new payment order */ createOrder(data: CreateOrderRequest) { return apiClient.post('/payment/orders', data) diff --git a/frontend/src/assets/icons/usdt.svg b/frontend/src/assets/icons/usdt.svg new file mode 100644 index 00000000000..3b03cf16f2c --- /dev/null +++ b/frontend/src/assets/icons/usdt.svg @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 677103bb328..29ce5244abb 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -213,6 +213,7 @@ const paymentTypeFilterOptions = computed(() => [ { value: 'wxpay', label: t('payment.methods.wxpay') }, { value: 'stripe', label: t('payment.methods.stripe') }, { value: 'airwallex', label: t('payment.methods.airwallex') }, + { value: 'usdt', label: t('payment.methods.usdt') }, ]) const orderTypeFilterOptions = computed(() => [ diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue index d84a3e154d6..284c751094f 100644 --- a/frontend/src/components/payment/PaymentMethodSelector.vue +++ b/frontend/src/components/payment/PaymentMethodSelector.vue @@ -33,6 +33,16 @@ +
+

+ {{ + usdtImpliedAmount && usdtImpliedAmount > 0 + ? t('payment.usdtRateLine', { cny: usdtRate.toFixed(2), usdt: (usdtImpliedAmount / usdtRate).toFixed(2) }) + : t('payment.usdtRateLineNoAmount', { cny: usdtRate.toFixed(2) }) + }} +

+

{{ t('payment.usdtRateNote') }}

+
@@ -44,6 +54,7 @@ import alipayIcon from '@/assets/icons/alipay.svg' import wxpayIcon from '@/assets/icons/wxpay.svg' import stripeIcon from '@/assets/icons/stripe.svg' import airwallexIcon from '@/assets/icons/airwallex.svg' +import usdtIcon from '@/assets/icons/usdt.svg' export interface PaymentMethodOption { type: string @@ -54,6 +65,8 @@ export interface PaymentMethodOption { const props = defineProps<{ methods: PaymentMethodOption[] selected: string + usdtRate?: number | null + usdtImpliedAmount?: number }>() const emit = defineEmits<{ @@ -67,6 +80,7 @@ const METHOD_ICONS: Record = { wxpay: wxpayIcon, stripe: stripeIcon, airwallex: airwallexIcon, + usdt: usdtIcon, } const sortedMethods = computed(() => { @@ -78,10 +92,13 @@ const sortedMethods = computed(() => { }) }) +const hasUsdt = computed(() => props.methods.some(m => m.type === 'usdt' && m.available)) + function methodIcon(type: string): string { if (type.includes('alipay')) return METHOD_ICONS.alipay if (type.includes('wxpay')) return METHOD_ICONS.wxpay if (type === 'airwallex') return METHOD_ICONS.airwallex + if (type === 'usdt') return METHOD_ICONS.usdt return METHOD_ICONS[type] || alipayIcon } @@ -90,6 +107,7 @@ function methodSelectedClass(type: string): string { if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100' if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100' if (type === 'airwallex') return 'border-[#FF6B3D] bg-orange-50 text-gray-900 shadow-sm dark:border-[#FF8E3C] dark:bg-orange-950 dark:text-gray-100' + if (type === 'usdt') return 'border-[#26A17B] bg-emerald-50 text-gray-900 shadow-sm dark:border-[#26A17B] dark:bg-emerald-950 dark:text-gray-100' return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100' } diff --git a/frontend/src/components/payment/ProviderCard.vue b/frontend/src/components/payment/ProviderCard.vue index e64d5d5ec95..c2c307e645b 100644 --- a/frontend/src/components/payment/ProviderCard.vue +++ b/frontend/src/components/payment/ProviderCard.vue @@ -77,6 +77,7 @@ const PROVIDER_KEY_LABELS: Record = { wxpay: 'admin.settings.payment.providerWxpay', stripe: 'admin.settings.payment.providerStripe', airwallex: 'admin.settings.payment.providerAirwallex', + usdt: 'admin.settings.payment.providerUsdt', } const props = defineProps<{ diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index ab5acf26db5..b534922a721 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -16,9 +16,10 @@ const VISIBLE_METHOD_ALIASES = { wxpay_direct: 'wxpay', stripe: 'stripe', airwallex: 'airwallex', + usdt: 'usdt', } as const -export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex' +export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex' | 'usdt' export type StripeVisibleMethod = 'alipay' | 'wechat_pay' export type PaymentLaunchKind = | 'qr_waiting' diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index 2b612b43028..94cff920c28 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -36,13 +36,14 @@ export const PROVIDER_SUPPORTED_TYPES: Record = { wxpay: ['wxpay'], stripe: ['card', 'alipay', 'wxpay', 'link'], airwallex: ['airwallex'], + usdt: ['usdt'], } /** Available payment modes for EasyPay providers. */ export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const /** Fixed display order for user-facing payment methods */ -export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex'] as const +export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe', 'airwallex', 'usdt'] as const /** Payment mode constants */ export const PAYMENT_MODE_QRCODE = 'qrcode' @@ -96,6 +97,7 @@ export const WEBHOOK_PATHS: Record = { wxpay: '/api/v1/payment/webhook/wxpay', stripe: '/api/v1/payment/webhook/stripe', airwallex: '/api/v1/payment/webhook/airwallex', + usdt: '/api/v1/payment/webhook/usdt', } export const RETURN_PATH = '/payment/result' @@ -105,6 +107,7 @@ export const PROVIDER_CALLBACK_PATHS: Record = { easypay: { notifyUrl: WEBHOOK_PATHS.easypay, returnUrl: RETURN_PATH }, alipay: { notifyUrl: WEBHOOK_PATHS.alipay, returnUrl: RETURN_PATH }, wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay }, + usdt: { notifyUrl: WEBHOOK_PATHS.usdt, returnUrl: RETURN_PATH }, // stripe: 不需要回调 URL 配置,Webhook 单独配置。 // airwallex: 不需要回调 URL 配置,Webhook 在空中云汇后台配置。 } @@ -118,6 +121,12 @@ export const PROVIDER_CONFIG_FIELDS: Record = { { key: 'cidAlipay', label: '', sensitive: false, optional: true }, { key: 'cidWxpay', label: '', sensitive: false, optional: true }, ], + usdt: [ + { key: 'pid', label: 'PID', sensitive: false }, + { key: 'pkey', label: 'PKey', sensitive: true }, + { key: 'apiBase', label: '', sensitive: false }, + { key: 'tradeType', label: '', sensitive: false, optional: true, defaultValue: 'usdt.trc20' }, + ], alipay: [ { key: 'appId', label: 'App ID', sensitive: false }, { key: 'privateKey', label: '', sensitive: true }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 6735029c7c3..688c861628b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5811,6 +5811,7 @@ export default { providerWxpay: 'WeChat Pay (Direct)', providerStripe: 'Stripe', providerAirwallex: 'Airwallex', + providerUsdt: 'USDT (Crypto)', typeDisabled: 'type disabled', enableTypesFirst: 'Enable at least one payment type above first', easypayRedirect: 'Redirect', @@ -5822,6 +5823,7 @@ export default { validationTypesRequired: 'Please select at least one supported payment type', validationFieldRequired: '{field} is required', field_apiBase: 'API Base URL', + field_tradeType: 'On-chain Trade Type', field_notifyUrl: 'Notify URL', field_returnUrl: 'Return URL', callbackBaseUrl: 'Callback Base URL', @@ -6751,6 +6753,7 @@ export default { wxpay: 'WeChat Pay', stripe: 'Stripe', airwallex: 'Airwallex', + usdt: 'USDT', card: 'Card', link: 'Link', alipay_direct: 'Alipay (Direct)', @@ -6831,6 +6834,9 @@ export default { amountTooHigh: 'Maximum amount is {max}', amountNoMethod: 'No payment method available for this amount', rechargeRatePreview: 'Current rate: 1 CNY = {usd} USD', + usdtRateLine: 'Live rate 1 USDT ≈ {cny} CNY · pay ~{usdt} USDT', + usdtRateLineNoAmount: 'Live rate 1 USDT ≈ {cny} CNY', + usdtRateNote: 'USDT rate refreshes every minute. Final amount shown on the checkout page is authoritative.', refundReason: 'Refund Reason', refundReasonPlaceholder: 'Please describe your refund reason', stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index abb8dff730e..19e0600b007 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5967,6 +5967,7 @@ export default { providerWxpay: '微信官方', providerStripe: 'Stripe', providerAirwallex: 'Airwallex', + providerUsdt: 'USDT(加密货币)', typeDisabled: '类型已禁用', enableTypesFirst: '请先在上方启用至少一种服务商', easypayRedirect: '跳转', @@ -5978,6 +5979,7 @@ export default { validationTypesRequired: '请至少选择一种支持的支付方式', validationFieldRequired: '{field} 不能为空', field_apiBase: 'API 基础地址', + field_tradeType: '链上交易类型', field_notifyUrl: '异步通知地址', field_returnUrl: '同步跳转地址', callbackBaseUrl: '回调基础地址', @@ -6931,6 +6933,7 @@ export default { wxpay: '微信支付', stripe: 'Stripe', airwallex: 'Airwallex', + usdt: 'USDT', card: '银行卡', link: 'Link', alipay_direct: '支付宝(直连)', @@ -7011,6 +7014,9 @@ export default { amountTooHigh: '最高金额为 {max}', amountNoMethod: '该金额没有可用的支付方式', rechargeRatePreview: '当前倍率:1 CNY = {usd} USD', + usdtRateLine: '实时汇率 1 USDT ≈ {cny} CNY · 实付约 {usdt} USDT', + usdtRateLineNoAmount: '实时汇率 1 USDT ≈ {cny} CNY', + usdtRateNote: 'USDT 汇率每分钟更新,实际支付以收银台显示金额为准', refundReason: '退款原因', refundReasonPlaceholder: '请描述您的退款原因', stripeLoadFailed: '支付组件加载失败,请刷新页面重试', diff --git a/frontend/src/style.css b/frontend/src/style.css index 1bd13b5f95a..e8b08f579d2 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -128,6 +128,13 @@ @apply dark:hover:bg-[#62d9ad]; } + .btn-usdt { + @apply bg-[#26A17B] text-white shadow-md shadow-[#26A17B]/25; + @apply hover:bg-[#218a69] hover:shadow-lg hover:shadow-[#26A17B]/30; + @apply active:bg-[#1c7457]; + @apply dark:shadow-[#26A17B]/20; + } + .btn-alipay { @apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25; @apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30; diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index d4c4fddaefb..cb04848dbc4 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -18,7 +18,7 @@ export type OrderStatus = | 'REFUNDED' | 'REFUND_FAILED' -export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' | 'airwallex' +export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay' | 'airwallex' | 'usdt' export type OrderType = 'balance' | 'subscription' @@ -218,3 +218,9 @@ export interface DashboardStats { payment_methods: { type: string; amount: number; count: number }[] top_users: { user_id: number; email: string; amount: number }[] } + +export interface UsdtRateResponse { + cny_per_usdt: number + updated_at: string + source: string +} diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 239ce2d7b72..4650c05028c 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -8900,6 +8900,7 @@ const allPaymentTypes = computed(() => [ { value: "wxpay", label: t("payment.methods.wxpay") }, { value: "stripe", label: t("payment.methods.stripe") }, { value: "airwallex", label: t("payment.methods.airwallex") }, + { value: "usdt", label: t("payment.methods.usdt") }, ]); function isPaymentTypeEnabled(type: string): boolean { @@ -8957,6 +8958,7 @@ const providerKeyOptions = computed(() => [ { value: "wxpay", label: t("admin.settings.payment.providerWxpay") }, { value: "stripe", label: t("admin.settings.payment.providerStripe") }, { value: "airwallex", label: t("admin.settings.payment.providerAirwallex") }, + { value: "usdt", label: t("admin.settings.payment.providerUsdt") }, ]); const enabledProviderKeyOptions = computed(() => { diff --git a/frontend/src/views/admin/orders/AdminOrdersView.vue b/frontend/src/views/admin/orders/AdminOrdersView.vue index 6619e20841d..e95e151c4dd 100644 --- a/frontend/src/views/admin/orders/AdminOrdersView.vue +++ b/frontend/src/views/admin/orders/AdminOrdersView.vue @@ -193,6 +193,7 @@ const paymentTypeFilterOptions = computed(() => [ { value: 'wxpay', label: t('payment.methods.wxpay') }, { value: 'stripe', label: t('payment.methods.stripe') }, { value: 'airwallex', label: t('payment.methods.airwallex') }, + { value: 'usdt', label: t('payment.methods.usdt') }, ]) const orderTypeFilterOptions = computed(() => [ diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index b7037b574e8..55fbadcaa65 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -54,6 +54,8 @@ @@ -245,7 +247,7 @@