diff --git a/README.fr.md b/README.fr.md index 6b4d0cebafee..3202877c0be5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -191,7 +191,7 @@ docker run --name new-api -d --restart always \ ### 💰 Paiement et facturation -- ✅ Recharge en ligne (EPay, Stripe) +- ✅ Recharge en ligne (EPay, Stripe, AllScale) - ✅ Tarification des modèles de paiement à l'utilisation - ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge) - ✅ Configuration flexible des politiques de facturation diff --git a/README.ja.md b/README.ja.md index 2b35bdfe9b93..0f9d467583dd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -191,7 +191,7 @@ docker run --name new-api -d --restart always \ ### 💰 支払いと課金 -- ✅ オンライン充電(EPay、Stripe) +- ✅ オンライン充電(EPay、Stripe、AllScale) - ✅ モデルの従量課金 - ✅ キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル) - ✅ 柔軟な課金ポリシー設定 diff --git a/README.md b/README.md index 8f23d5dcd380..c871b940cc77 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ docker run --name new-api -d --restart always \ ### 💰 Payment and Billing -- ✅ Online recharge (EPay, Stripe) +- ✅ Online recharge (EPay, Stripe, AllScale) - ✅ Pay-per-use model pricing - ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models) - ✅ Flexible billing policy configuration diff --git a/README.zh_CN.md b/README.zh_CN.md index 92e5baa1d212..b567806bd814 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -191,7 +191,7 @@ docker run --name new-api -d --restart always \ ### 💰 支付与计费 -- ✅ 在线充值(易支付、Stripe) +- ✅ 在线充值(易支付、Stripe、AllScale) - ✅ 模型按次数收费 - ✅ 缓存计费支持(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型) - ✅ 灵活的计费策略配置 diff --git a/README.zh_TW.md b/README.zh_TW.md index 9264bc722d42..f71901801f78 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -188,7 +188,7 @@ docker run --name new-api -d --restart always \ ### 💰 支付與計費 -- ✅ 在線儲值(易支付、Stripe) +- ✅ 在線儲值(易支付、Stripe、AllScale) - ✅ 模型按次數收費 - ✅ 快取計費支援(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支援的模型) - ✅ 靈活的計費策略配置 diff --git a/controller/topup.go b/controller/topup.go index e7a392a4d31d..9cccc0f5d7fa 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -78,24 +78,28 @@ func GetTopUpInfo(c *gin.Context) { } } + enableAllScale := setting.AllScaleEnabled && setting.AllScaleApiKey != "" && setting.AllScaleApiSecret != "" + data := gin.H{ - "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", - "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", - "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", - "enable_waffo_topup": enableWaffo, + "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", + "enable_waffo_topup": enableWaffo, + "enable_allscale_topup": enableAllScale, "waffo_pay_methods": func() interface{} { if enableWaffo { return setting.GetWaffoPayMethods() } return nil }(), - "creem_products": setting.CreemProducts, - "pay_methods": payMethods, - "min_topup": operation_setting.MinTopUp, - "stripe_min_topup": setting.StripeMinTopUp, - "waffo_min_topup": setting.WaffoMinTopUp, - "amount_options": operation_setting.GetPaymentSetting().AmountOptions, - "discount": operation_setting.GetPaymentSetting().AmountDiscount, + "creem_products": setting.CreemProducts, + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "waffo_min_topup": setting.WaffoMinTopUp, + "allscale_min_topup": setting.AllScaleMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, } common.ApiSuccess(c, data) } diff --git a/controller/topup_allscale.go b/controller/topup_allscale.go new file mode 100644 index 000000000000..0da4faa09418 --- /dev/null +++ b/controller/topup_allscale.go @@ -0,0 +1,644 @@ +package controller + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/thanhpk/randstr" +) + +// AllScale checkout intent numeric status codes +const ( + allScaleCheckoutStatusFailed = -1 + allScaleCheckoutStatusRejected = -2 + allScaleCheckoutStatusUnderpaid = -3 + allScaleCheckoutStatusCanceled = -4 + allScaleCheckoutStatusCreated = 1 + allScaleCheckoutStatusConfirmed = 20 +) + +// ── outgoing request signing ───────────────────────────────────────────────── + +func allScaleSha256Hex(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func allScaleSign(secret string, canonical []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(canonical) + return "v1=" + base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +// buildAllScaleRequestHeaders signs a request to the AllScale API and returns +// the four auth headers ready to set on the outgoing http.Request. +func buildAllScaleRequestHeaders(method, path, query string, body []byte) (apiKey, timestamp, nonce, signature string) { + apiKey = setting.AllScaleApiKey + timestamp = fmt.Sprintf("%d", time.Now().Unix()) + nonce = uuid.New().String() + bodyHash := allScaleSha256Hex(body) + canonical := []byte(strings.Join([]string{ + strings.ToUpper(method), path, query, timestamp, nonce, bodyHash, + }, "\n")) + signature = allScaleSign(setting.AllScaleApiSecret, canonical) + return +} + +const maxAllScaleWebhookBody = 64 << 10 // 64 KiB + +// ── webhook signature verification ────────────────────────────────────────── + +// verifyAllScaleWebhook verifies the HMAC-SHA256 signature AllScale sends on +// every webhook callback (domain-separated from the request signing scheme). +func verifyAllScaleWebhook(requestPath, queryString, webhookId, timestamp, nonce string, body []byte, sigHeader string) bool { + if setting.AllScaleApiSecret == "" { + log.Printf("AllScale webhook secret not configured") + return false + } + if setting.AllScaleWebhookID != "" && webhookId != setting.AllScaleWebhookID { + log.Printf("AllScale webhook: webhook_id mismatch (got=%s, want=%s)", webhookId, setting.AllScaleWebhookID) + return false + } + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + log.Printf("AllScale webhook: invalid timestamp format") + return false + } + now := time.Now().Unix() + if now-ts > 300 || ts-now > 60 { + log.Printf("AllScale webhook: timestamp outside acceptable window (ts=%d, now=%d)", ts, now) + return false + } + bodyHash := allScaleSha256Hex(body) + canonical := []byte(strings.Join([]string{ + "allscale:webhook:v1", + "POST", + requestPath, + queryString, + webhookId, + timestamp, + nonce, + bodyHash, + }, "\n")) + expected := allScaleSign(setting.AllScaleApiSecret, canonical) + return hmac.Equal([]byte(expected), []byte(sigHeader)) +} + +// ── pay-money calculation ──────────────────────────────────────────────────── + +func getAllScalePayMoney(amount float64, group string) float64 { + originalAmount := amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + amount = amount / common.QuotaPerUnit + } + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok && ds > 0 { + discount = ds + } + return amount * topupGroupRatio * discount +} + +// ── DTOs ───────────────────────────────────────────────────────────────────── + +type AllScalePayRequest struct { + Amount int64 `json:"amount"` +} + +// AllScale API response wrapper +type allScaleAPIResponse struct { + Code int `json:"code"` + Payload *struct { + CheckoutURL string `json:"checkout_url"` + AllScaleCheckoutIntentId string `json:"allscale_checkout_intent_id"` + } `json:"payload"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// allScaleStatusResponse is the response from GET /v1/checkout_intents/{id}/ +type allScaleStatusResponse struct { + Code int `json:"code"` + Payload *struct { + Status string `json:"status"` // PENDING_PAYMENT, PAID, EXPIRED, FAILED + AllScaleCheckoutIntentId string `json:"allscale_checkout_intent_id"` + } `json:"payload"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// AllScaleWebhookPayload mirrors WebhookCallbackPayload from callback_service.py. +type AllScaleWebhookPayload struct { + AllScaleTransactionId string `json:"all_scale_transaction_id"` + AllScaleCheckoutIntentId string `json:"all_scale_checkout_intent_id"` + WebhookId string `json:"webhook_id"` + AmountCents int `json:"amount_cents"` + Currency int `json:"currency"` + CurrencySymbol string `json:"currency_symbol"` + AmountCoins string `json:"amount_coins"` + CoinContractAddress string `json:"coin_contract_address"` + CoinSymbol string `json:"coin_symbol"` + ChainId int `json:"chain_id"` + TxHash string `json:"tx_hash"` + TxFrom string `json:"tx_from"` + PaymentMethodType int `json:"payment_method_type"` + UserId *string `json:"user_id"` + OrderId *string `json:"order_id"` // our tradeNo + UserName *string `json:"user_name"` + ExtraObj map[string]any `json:"extra_obj"` +} + +// ── handlers ───────────────────────────────────────────────────────────────── + +// RequestAllScalePay creates an AllScale checkout intent and returns the +// checkout_url for the user to complete payment in crypto (USDT/USDC). +func RequestAllScalePay(c *gin.Context) { + if !setting.AllScaleEnabled { + c.JSON(200, gin.H{"message": "error", "data": "AllScale payment is not enabled"}) + return + } + if setting.AllScaleApiKey == "" || setting.AllScaleApiSecret == "" { + c.JSON(200, gin.H{"message": "error", "data": "AllScale payment is not configured"}) + return + } + + var req AllScalePayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(200, gin.H{"message": "error", "data": "invalid request"}) + return + } + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil || user == nil { + c.JSON(200, gin.H{"message": "error", "data": "user not found"}) + return + } + + group, _ := model.GetUserGroup(userId, true) + payMoney := getAllScalePayMoney(float64(req.Amount), group) + + // Apply currency rate and round early so the minimum check uses the actual USD charge. + unitPrice := setting.AllScaleUnitPrice + if unitPrice <= 0 { + unitPrice = 1.0 + } + roundedUSD := math.Round(payMoney/unitPrice*100) / 100 + + minTopUp := setting.AllScaleMinTopUp + if minTopUp <= 0 { + minTopUp = 1.0 + } + if roundedUSD < minTopUp { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("minimum payment is $%.2f USD", minTopUp)}) + return + } + + // Normalise stored amount for token-display mode (mirrors Waffo pattern). + amount := req.Amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + quotaPerUnit := int64(common.QuotaPerUnit) + if quotaPerUnit > 0 && req.Amount%quotaPerUnit != 0 { + c.JSON(200, gin.H{"message": "error", "data": "token amount must be a multiple of the quota unit size"}) + return + } + amount = req.Amount / quotaPerUnit + if amount < 1 { + amount = 1 + } + } + + tradeNo := fmt.Sprintf("ALLSCALE-%d-%d-%s", userId, time.Now().UnixMilli(), randstr.String(6)) + + // Persist a pending order before calling the external API. + topUp := &model.TopUp{ + UserId: userId, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: "allscale", + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := topUp.Insert(); err != nil { + log.Printf("AllScale: failed to insert TopUp: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "failed to create order"}) + return + } + + // Build the checkout-intent request body. + // roundedUSD and unitPrice were already computed above for the minimum check. + amountCents := int64(math.Round(roundedUSD * 100)) + redirectURL := system_setting.ServerAddress + "/console/topup?show_history=true" + + reqBody, _ := common.Marshal(map[string]any{ + "currency": 1, // USD = 1 + "amount_cents": amountCents, + "order_id": tradeNo, // reconciliation key returned in webhook + "user_id": fmt.Sprintf("%d", userId), + "user_name": user.Username, + "order_description": fmt.Sprintf("Top up %d credits", req.Amount), + "redirect_url": redirectURL, + }) + + // Sign and send to AllScale API. + apiURL := strings.TrimRight(setting.AllScaleBaseURL, "/") + "/v1/checkout_intents/" + apiKey, ts, nonce, sig := buildAllScaleRequestHeaders("POST", "/v1/checkout_intents/", "", reqBody) + + httpReq, err := http.NewRequestWithContext(c.Request.Context(), "POST", apiURL, bytes.NewReader(reqBody)) + if err != nil { + log.Printf("AllScale: failed to build request: %v", err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "failed to initiate payment"}) + return + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-API-Key", apiKey) + httpReq.Header.Set("X-Timestamp", ts) + httpReq.Header.Set("X-Nonce", nonce) + httpReq.Header.Set("X-Signature", sig) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + log.Printf("AllScale: API call failed: %v", err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "failed to initiate payment"}) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("AllScale: failed to read response body: %v", err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "failed to read payment response"}) + return + } + + if resp.StatusCode/100 != 2 { + log.Printf("AllScale: API returned HTTP %d: %s", resp.StatusCode, respBody) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "payment gateway error"}) + return + } + + var apiResp allScaleAPIResponse + if err := common.Unmarshal(respBody, &apiResp); err != nil || apiResp.Code != 0 || apiResp.Payload == nil { + errMsg := "unexpected response from payment gateway" + if apiResp.Error != nil { + errMsg = apiResp.Error.Message + } + log.Printf("AllScale: API error (code=%d): %s", apiResp.Code, errMsg) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": errMsg}) + return + } + + // Persist the intent ID so we can verify it on status polls (prevents replay with foreign intent IDs). + if apiResp.Payload.AllScaleCheckoutIntentId == "" { + log.Printf("AllScale: empty checkout intent id - tradeNo=%s", tradeNo) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "unexpected response from payment gateway"}) + return + } + topUp.CheckoutIntentId = apiResp.Payload.AllScaleCheckoutIntentId + if err := topUp.Update(); err != nil { + log.Printf("AllScale: failed to persist checkout intent - tradeNo=%s err=%v", tradeNo, err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "failed to persist payment intent"}) + return + } + + log.Printf("AllScale: checkout intent created - user=%d tradeNo=%s intentId=%s amount=$%.2f", + userId, tradeNo, apiResp.Payload.AllScaleCheckoutIntentId, payMoney) + + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "checkout_url": apiResp.Payload.CheckoutURL, + "order_id": tradeNo, + "intent_id": apiResp.Payload.AllScaleCheckoutIntentId, + }, + }) +} + +// AllScaleWebhook receives the payment-completed callback from AllScale and +// credits the corresponding user's quota. +func AllScaleWebhook(c *gin.Context) { + bodyBytes, err := io.ReadAll(io.LimitReader(c.Request.Body, maxAllScaleWebhookBody+1)) + if err != nil { + log.Printf("AllScale webhook: failed to read body: %v", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + if int64(len(bodyBytes)) > maxAllScaleWebhookBody { + log.Printf("AllScale webhook: body too large (%d bytes)", len(bodyBytes)) + c.AbortWithStatus(http.StatusRequestEntityTooLarge) + return + } + + webhookId := c.GetHeader("X-Webhook-Id") + timestamp := c.GetHeader("X-Webhook-Timestamp") + nonce := c.GetHeader("X-Webhook-Nonce") + sigHeader := c.GetHeader("X-Webhook-Signature") + + if webhookId == "" || timestamp == "" || nonce == "" || sigHeader == "" { + log.Printf("AllScale webhook: missing required headers") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + requestPath := c.Request.URL.Path + queryString := c.Request.URL.RawQuery + + if !verifyAllScaleWebhook(requestPath, queryString, webhookId, timestamp, nonce, bodyBytes, sigHeader) { + log.Printf("AllScale webhook: signature verification failed") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + var payload AllScaleWebhookPayload + if err := common.Unmarshal(bodyBytes, &payload); err != nil { + log.Printf("AllScale webhook: failed to parse payload: %v", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + if payload.OrderId == nil || *payload.OrderId == "" { + log.Printf("AllScale webhook: missing order_id, intentId=%s", payload.AllScaleCheckoutIntentId) + // Not an error on our side — acknowledge to avoid retries. + c.Status(http.StatusOK) + return + } + + tradeNo := *payload.OrderId + log.Printf("AllScale webhook: received - tradeNo=%s intentId=%s amountCents=%d coin=%s", + tradeNo, payload.AllScaleCheckoutIntentId, payload.AmountCents, payload.CoinSymbol) + + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + + if err := model.RechargeAllScale(tradeNo); err != nil { + log.Printf("AllScale webhook: recharge failed - tradeNo=%s err=%v", tradeNo, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + log.Printf("AllScale webhook: recharge successful - tradeNo=%s", tradeNo) + c.Status(http.StatusOK) +} + +// RequestAllScaleAmount returns the payment amount (in USD) for a given quota +// amount, without creating an order. Used by the frontend for live price previews. +func RequestAllScaleAmount(c *gin.Context) { + if !setting.AllScaleEnabled { + c.JSON(200, gin.H{"message": "error", "data": "AllScale payment is not enabled"}) + return + } + var req AllScalePayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(200, gin.H{"message": "error", "data": "invalid request"}) + return + } + userId := c.GetInt("id") + + // Mirror the same token-display validation as RequestAllScalePay. + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + quotaPerUnit := int64(common.QuotaPerUnit) + if quotaPerUnit > 0 && req.Amount%quotaPerUnit != 0 { + c.JSON(200, gin.H{"message": "error", "data": "token amount must be a multiple of the quota unit size"}) + return + } + } + + group, _ := model.GetUserGroup(userId, true) + payMoney := getAllScalePayMoney(float64(req.Amount), group) + unitPrice := setting.AllScaleUnitPrice + if unitPrice <= 0 { + unitPrice = 1.0 + } + c.JSON(200, gin.H{ + "message": "success", + "data": strconv.FormatFloat(payMoney/unitPrice, 'f', 2, 64), + }) +} + +// allScaleStatusAPIResponse is the response wrapper from GET /v1/checkout_intents/{id}/status. +// The payload is either a plain integer or {"status": int}. +type allScaleStatusAPIResponse struct { + Code int `json:"code"` + Payload json.RawMessage `json:"payload"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// parseAllScaleNumericStatus parses the payload from the /status endpoint, +// which can be either a plain integer or {"status": int}. +func parseAllScaleNumericStatus(raw []byte) (int, error) { + var status int + if err := common.Unmarshal(raw, &status); err == nil { + return status, nil + } + var payload struct { + Status int `json:"status"` + } + if err := common.Unmarshal(raw, &payload); err == nil { + return payload.Status, nil + } + return 0, fmt.Errorf("invalid allscale status payload") +} + +// getAllScaleFinalTopUpStatus maps a terminal numeric status to a TopUp status string. +func getAllScaleFinalTopUpStatus(status int) string { + switch status { + case allScaleCheckoutStatusCanceled: + return common.TopUpStatusExpired + case allScaleCheckoutStatusFailed, allScaleCheckoutStatusRejected, allScaleCheckoutStatusUnderpaid: + return common.TopUpStatusFailed + default: + return "" + } +} + +// GetAllScaleStatus polls the AllScale API for the checkout intent status and, +// on a successful payment, credits the user's quota. +func GetAllScaleStatus(c *gin.Context) { + tradeNo := c.Query("trade_no") + if tradeNo == "" { + c.JSON(200, gin.H{"message": "error", "data": "missing trade_no"}) + return + } + + userId := c.GetInt("id") + + // Load the order and validate ownership. + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil || topUp.UserId != userId { + c.JSON(200, gin.H{"message": "error", "data": "order not found"}) + return + } + if topUp.PaymentMethod != "allscale" { + c.JSON(200, gin.H{"message": "error", "data": "order not found"}) + return + } + + // Use the DB-stored intent ID — never trust the caller-supplied value. + intentId := topUp.CheckoutIntentId + if intentId == "" { + c.JSON(200, gin.H{"message": "error", "data": "intent not found"}) + return + } + + // Fast path: if the order is already in a terminal state, return it directly. + if topUp.Status == common.TopUpStatusSuccess || topUp.Status == common.TopUpStatusFailed || topUp.Status == common.TopUpStatusExpired { + localStatus := allScaleCheckoutStatusCreated + switch topUp.Status { + case common.TopUpStatusSuccess: + localStatus = allScaleCheckoutStatusConfirmed + case common.TopUpStatusFailed: + localStatus = allScaleCheckoutStatusFailed + case common.TopUpStatusExpired: + localStatus = allScaleCheckoutStatusCanceled + } + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "status": localStatus, + "topup_status": topUp.Status, + "completed": topUp.Status == common.TopUpStatusSuccess, + }, + }) + return + } + + // Hit the AllScale /status endpoint for the current intent status. + path := "/v1/checkout_intents/" + intentId + "/status" + apiURL := strings.TrimRight(setting.AllScaleBaseURL, "/") + path + apiKey, ts, nonce, sig := buildAllScaleRequestHeaders("GET", path, "", []byte{}) + + httpReq, err := http.NewRequestWithContext(c.Request.Context(), "GET", apiURL, nil) + if err != nil { + log.Printf("AllScale status: failed to build request: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "failed to build request"}) + return + } + httpReq.Header.Set("X-API-Key", apiKey) + httpReq.Header.Set("X-Timestamp", ts) + httpReq.Header.Set("X-Nonce", nonce) + httpReq.Header.Set("X-Signature", sig) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + log.Printf("AllScale status: API call failed: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "failed to contact payment gateway"}) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("AllScale status: failed to read response body: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "failed to read status response"}) + return + } + + var statusResp allScaleStatusAPIResponse + if err := common.Unmarshal(respBody, &statusResp); err != nil || statusResp.Code != 0 { + errMsg := "unexpected response from payment gateway" + if statusResp.Error != nil { + errMsg = statusResp.Error.Message + } + log.Printf("AllScale status: bad response for intentId=%s: %s", intentId, respBody) + c.JSON(200, gin.H{"message": "error", "data": errMsg}) + return + } + + status, err := parseAllScaleNumericStatus(statusResp.Payload) + if err != nil { + log.Printf("AllScale status: failed to parse payload for intentId=%s: %s", intentId, respBody) + c.JSON(200, gin.H{"message": "error", "data": "failed to parse payment status"}) + return + } + + log.Printf("AllScale status: intentId=%s tradeNo=%s status=%d", intentId, tradeNo, status) + + if status == allScaleCheckoutStatusConfirmed { + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + if err := model.RechargeAllScale(tradeNo); err != nil { + log.Printf("AllScale status poll: recharge failed - tradeNo=%s err=%v", tradeNo, err) + c.JSON(200, gin.H{"message": "error", "data": err.Error()}) + return + } + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "status": status, + "topup_status": common.TopUpStatusSuccess, + "completed": true, + }, + }) + return + } + + if finalStatus := getAllScaleFinalTopUpStatus(status); finalStatus != "" { + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + // Update local DB to reflect terminal failure. + if tu := model.GetTopUpByTradeNo(tradeNo); tu != nil && tu.Status == common.TopUpStatusPending { + tu.Status = finalStatus + _ = tu.Update() + } + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "status": status, + "topup_status": finalStatus, + "completed": false, + }, + }) + return + } + + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "status": status, + "topup_status": common.TopUpStatusPending, + "completed": false, + }, + }) +} \ No newline at end of file diff --git a/model/option.go b/model/option.go index 967fa0aa6708..6d61a0468f87 100644 --- a/model/option.go +++ b/model/option.go @@ -105,6 +105,15 @@ func InitOptionMap() { common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64) common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp) common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString() + common.OptionMap["AllScaleEnabled"] = strconv.FormatBool(setting.AllScaleEnabled) + common.OptionMap["AllScaleApiKey"] = setting.AllScaleApiKey + common.OptionMap["AllScaleApiSecret"] = setting.AllScaleApiSecret + common.OptionMap["AllScaleWebhookID"] = setting.AllScaleWebhookID + common.OptionMap["AllScaleBaseURL"] = setting.AllScaleBaseURL + common.OptionMap["AllScaleUnitPrice"] = strconv.FormatFloat(setting.AllScaleUnitPrice, 'f', -1, 64) + common.OptionMap["AllScaleMinTopUp"] = strconv.FormatFloat(setting.AllScaleMinTopUp, 'f', -1, 64) + common.OptionMap["AllScaleApiKeySet"] = strconv.FormatBool(setting.AllScaleApiKey != "") + common.OptionMap["AllScaleApiSecretSet"] = strconv.FormatBool(setting.AllScaleApiSecret != "") common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -404,6 +413,22 @@ func updateOptionMap(key string, value string) (err error) { setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64) case "WaffoMinTopUp": setting.WaffoMinTopUp, _ = strconv.Atoi(value) + case "AllScaleEnabled": + setting.AllScaleEnabled = value == "true" + case "AllScaleApiKey": + setting.AllScaleApiKey = value + common.OptionMap["AllScaleApiKeySet"] = strconv.FormatBool(value != "") + case "AllScaleApiSecret": + setting.AllScaleApiSecret = value + common.OptionMap["AllScaleApiSecretSet"] = strconv.FormatBool(value != "") + case "AllScaleBaseURL": + setting.AllScaleBaseURL = value + case "AllScaleWebhookID": + setting.AllScaleWebhookID = value + case "AllScaleUnitPrice": + setting.AllScaleUnitPrice, _ = strconv.ParseFloat(value, 64) + case "AllScaleMinTopUp": + setting.AllScaleMinTopUp, _ = strconv.ParseFloat(value, 64) case "TopupGroupRatio": err = common.UpdateTopupGroupRatioByJSONString(value) case "GitHubClientId": diff --git a/model/topup.go b/model/topup.go index d8c92bfe6517..b9d1dbea1570 100644 --- a/model/topup.go +++ b/model/topup.go @@ -12,15 +12,16 @@ import ( ) type TopUp struct { - Id int `json:"id"` - UserId int `json:"user_id" gorm:"index"` - Amount int64 `json:"amount"` - Money float64 `json:"money"` - TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` - PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` - CreateTime int64 `json:"create_time"` - CompleteTime int64 `json:"complete_time"` - Status string `json:"status"` + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int64 `json:"amount"` + Money float64 `json:"money"` + TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` + CheckoutIntentId string `json:"checkout_intent_id" gorm:"type:varchar(255)"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + Status string `json:"status"` } func (topUp *TopUp) Insert() error { @@ -377,6 +378,64 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return nil } +func RechargeAllScale(tradeNo string) (err error) { + if tradeNo == "" { + return errors.New("missing trade number") + } + + var quotaToAdd int + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { + return errors.New("order not found") + } + + if topUp.Status == common.TopUpStatusSuccess { + return nil // idempotent + } + + if topUp.PaymentMethod != "allscale" { + return errors.New("order payment method mismatch") + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("order is not in pending status") + } + + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + if quotaToAdd <= 0 { + return errors.New("invalid quota amount") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + return tx.Model(&User{}).Where("id = ?", topUp.UserId). + Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error + }) + + if err != nil { + common.SysError("allscale topup failed: " + err.Error()) + return errors.New("top-up failed, please try again later") + } + + if quotaToAdd > 0 { + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("AllScale top-up successful, quota: %v, payment: %.2f USD", logger.FormatQuota(quotaToAdd), topUp.Money)) + } + return nil +} + func RechargeWaffo(tradeNo string) (err error) { if tradeNo == "" { return errors.New("未提供支付单号") diff --git a/router/api-router.go b/router/api-router.go index 31ab771a50bd..7a8bd329499c 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) + apiRouter.POST("/allscale/webhook", middleware.CriticalRateLimit(), controller.AllScaleWebhook) // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) @@ -91,6 +92,9 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) + selfRoute.POST("/allscale/amount", controller.RequestAllScaleAmount) + selfRoute.POST("/allscale/pay", middleware.CriticalRateLimit(), controller.RequestAllScalePay) + selfRoute.GET("/allscale/status", controller.GetAllScaleStatus) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) diff --git a/setting/payment_allscale.go b/setting/payment_allscale.go new file mode 100644 index 000000000000..868911717786 --- /dev/null +++ b/setting/payment_allscale.go @@ -0,0 +1,9 @@ +package setting + +var AllScaleEnabled = false +var AllScaleApiKey = "" +var AllScaleApiSecret = "" +var AllScaleWebhookID = "" +var AllScaleBaseURL = "https://openapi.allscale.io" +var AllScaleUnitPrice float64 = 1 +var AllScaleMinTopUp float64 = 1 diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 928d58a74bb0..abba955165af 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -24,6 +24,7 @@ import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentG import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo'; +import SettingsPaymentGatewayAllScale from '../../pages/Setting/Payment/SettingsPaymentGatewayAllScale'; import { API, showError, toBoolean } from '../../helpers'; import { useTranslation } from 'react-i18next'; @@ -138,6 +139,9 @@ const PaymentSetting = () => { + + + diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index f37d129b33d3..80bfdb2cbe54 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -90,6 +90,10 @@ const RechargeCard = ({ enableWaffoTopUp, waffoTopUp, waffoPayMethods, + enableAllScaleTopUp, + allScaleTopUp, + allScaleAmount = 0, + getAllScaleAmount, subscriptionLoading = false, subscriptionPlans = [], billingPreference, @@ -227,19 +231,19 @@ const RechargeCard = ({
- ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? ( + ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp || enableAllScaleTopUp ? (
(onlineFormApiRef.current = api)} initValues={{ topUpCount: topUpCount }} >
- {(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && ( + {(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp || enableAllScaleTopUp) && ( { @@ -260,6 +267,9 @@ const RechargeCard = ({ if (!value || value < 1) { setTopUpCount(1); getAmount(1); + if (enableAllScaleTopUp && getAllScaleAmount) { + getAllScaleAmount(1); + } } }} formatter={(value) => (value ? `${value}` : '')} @@ -291,11 +301,11 @@ const RechargeCard = ({ style={{ width: '100%' }} /> - {payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && ( + {(payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 || enableAllScaleTopUp) && ( - {payMethods.filter(m => m.type !== 'waffo').map((payMethod) => { + {payMethods && payMethods.filter(m => m.type !== 'waffo').map((payMethod) => { const minTopupVal = Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; const disabled = @@ -354,6 +364,19 @@ const RechargeCard = ({ ); })} + {enableAllScaleTopUp && ( + + )} @@ -361,7 +384,7 @@ const RechargeCard = ({ )} - {(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && ( + {(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp || enableAllScaleTopUp) && ( diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 0348e3c8dd93..6b47d31470d2 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState, useContext, useRef } from 'react'; +import React, { useEffect, useState, useContext, useRef, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { API, @@ -40,12 +40,26 @@ import TransferModal from './modals/TransferModal'; import PaymentConfirmModal from './modals/PaymentConfirmModal'; import TopupHistoryModal from './modals/TopupHistoryModal'; +const ALLSCALE_MAX_POLL_ATTEMPTS = 120; +const ALLSCALE_POLL_INTERVAL = 5000; +const ALLSCALE_STATUS = { + FAILED: -1, + REJECTED: -2, + UNDERPAID: -3, + CANCELED: -4, + ON_CHAIN: 10, + CONFIRMED: 20, +}; + const TopUp = () => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const [userState, userDispatch] = useContext(UserContext); const [statusState] = useContext(StatusContext); + // User-scoped localStorage key prevents one user from resuming another's pending payment. + const allScalePendingKey = `allscale_pending_payment_${userState?.user?.id ?? 'guest'}`; + const [redemptionCode, setRedemptionCode] = useState(''); const [amount, setAmount] = useState(0.0); const [minTopUp, setMinTopUp] = useState(statusState?.status?.min_topup || 1); @@ -76,6 +90,16 @@ const TopUp = () => { const [waffoPayMethods, setWaffoPayMethods] = useState([]); const [waffoMinTopUp, setWaffoMinTopUp] = useState(1); + // AllScale 相关状态 + const [enableAllScaleTopUp, setEnableAllScaleTopUp] = useState(false); + const [allScaleAmount, setAllScaleAmount] = useState(0); + const [allScaleMinTopUp, setAllScaleMinTopUp] = useState(1); + + // AllScale polling + const allScalePollingRef = useRef(null); + const allScalePollSessionRef = useRef(0); + const allScaleAmountReqRef = useRef(0); + const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); const [payWay, setPayWay] = useState(''); @@ -93,6 +117,7 @@ const TopUp = () => { // 账单Modal状态 const [openHistory, setOpenHistory] = useState(false); + const [historyReloadKey, setHistoryReloadKey] = useState(0); // 订阅相关 const [subscriptionPlans, setSubscriptionPlans] = useState([]); @@ -346,6 +371,182 @@ const TopUp = () => { } }; + // ── AllScale polling helpers ────────────────────────────────────────────── + + const clearAllScalePolling = useCallback(() => { + allScalePollSessionRef.current += 1; + if (allScalePollingRef.current) { + clearInterval(allScalePollingRef.current); + allScalePollingRef.current = null; + } + }, []); + + const clearAllScalePendingPayment = useCallback(() => { + localStorage.removeItem(allScalePendingKey); + }, [allScalePendingKey]); + + const pollAllScaleStatusOnce = async (tradeNo) => { + const res = await API.get('/api/user/allscale/status', { + params: { trade_no: tradeNo }, + }); + const { message, data } = res.data; + if (message !== 'success') { + throw new Error( + typeof data === 'string' ? data : message || t('支付请求失败'), + ); + } + + const status = Number(data?.status); + const topupStatus = data?.topup_status; + + if ( + topupStatus === 'success' || + status === ALLSCALE_STATUS.CONFIRMED || + data?.completed + ) { + return { state: 'success' }; + } + + if (topupStatus === 'failed' || topupStatus === 'expired' || status < 0) { + return { + state: 'failed', + message: + status === ALLSCALE_STATUS.CANCELED + ? t('支付已取消') + : status === ALLSCALE_STATUS.UNDERPAID + ? t('支付金额不足') + : status === ALLSCALE_STATUS.REJECTED + ? t('支付被拒绝') + : t('支付失败'), + }; + } + + return { state: 'pending' }; + }; + + const startAllScalePolling = useCallback((intentId, tradeNo) => { + clearAllScalePolling(); + const sessionId = allScalePollSessionRef.current; + localStorage.setItem( + allScalePendingKey, + JSON.stringify({ intentId, tradeNo, startedAt: Date.now() }) + ); + let attempts = 0; + let inFlight = false; + allScalePollingRef.current = setInterval(async () => { + if (inFlight) return; + attempts += 1; + if (attempts > ALLSCALE_MAX_POLL_ATTEMPTS) { + clearAllScalePolling(); + showInfo(t('支付处理中,请稍后在充值账单中查看结果')); + return; + } + inFlight = true; + try { + const result = await pollAllScaleStatusOnce(tradeNo); + if (allScalePollSessionRef.current !== sessionId) return; + if (result.state === 'success') { + clearAllScalePolling(); + clearAllScalePendingPayment(); + showSuccess(t('充值成功')); + getUserQuota().then(); + setHistoryReloadKey((k) => k + 1); + } else if (result.state === 'failed') { + clearAllScalePolling(); + clearAllScalePendingPayment(); + showError(result.message || t('支付失败')); + setHistoryReloadKey((k) => k + 1); + } + } catch (error) { + if (allScalePollSessionRef.current !== sessionId) return; + if (attempts >= ALLSCALE_MAX_POLL_ATTEMPTS) { + clearAllScalePolling(); + showInfo(t('支付处理中,请稍后在充值账单中查看结果')); + } + } finally { + inFlight = false; + } + }, ALLSCALE_POLL_INTERVAL); + }, [allScalePendingKey, clearAllScalePendingPayment, clearAllScalePolling, t]); + + const resumePendingAllScalePayment = useCallback(() => { + const raw = localStorage.getItem(allScalePendingKey); + if (!raw) return; + try { + const pending = JSON.parse(raw); + if (!pending?.intentId || !pending?.tradeNo) { + clearAllScalePendingPayment(); + return; + } + if (pending.startedAt && Date.now() - pending.startedAt > 30 * 60 * 1000) { + clearAllScalePendingPayment(); + return; + } + startAllScalePolling(pending.intentId, pending.tradeNo); + } catch (e) { + clearAllScalePendingPayment(); + } + }, [allScalePendingKey, clearAllScalePendingPayment, startAllScalePolling]); + + const getAllScaleAmount = async (value) => { + if (value === undefined) value = topUpCount; + const reqId = ++allScaleAmountReqRef.current; + try { + const res = await API.post('/api/user/allscale/amount', { + amount: parseFloat(value), + }); + if (allScaleAmountReqRef.current !== reqId) return; // stale response + if (res !== undefined) { + const { message, data } = res.data; + if (message === 'success') { + setAllScaleAmount(parseFloat(data)); + } else { + setAllScaleAmount(0); + } + } + } catch (e) { + if (allScaleAmountReqRef.current !== reqId) return; + setAllScaleAmount(0); + } + }; + + // ───────────────────────────────────────────────────────────────────────── + + // Creates the checkout intent and immediately opens the AllScale checkout page. + const allScaleTopUp = async () => { + if (allScaleAmount <= 0) { + showError(t('无法获取支付金额,请稍后重试')); + return; + } + if (allScaleAmount < allScaleMinTopUp) { + showError(`${t('最低充值金额 (USD)')}: $${allScaleMinTopUp}`); + return; + } + setPaymentLoading(true); + try { + const res = await API.post('/api/user/allscale/pay', { + amount: parseInt(topUpCount), + }); + if (res !== undefined) { + const { message, data } = res.data; + if (message === 'success' && data?.checkout_url) { + window.open(data.checkout_url, '_blank', 'noopener,noreferrer'); + if (data.intent_id && data.order_id) { + startAllScalePolling(data.intent_id, data.order_id); + } + } else { + showError(data || t('支付请求失败')); + } + } else { + showError(res); + } + } catch (e) { + showError(t('支付请求失败')); + } finally { + setPaymentLoading(false); + } + }; + const processCreemCallback = (data) => { // 与 Stripe 保持一致的实现方式 window.open(data.checkout_url, '_blank'); @@ -495,6 +696,9 @@ const TopUp = () => { setEnableWaffoTopUp(enableWaffoTopUp); setWaffoPayMethods(data.waffo_pay_methods || []); setWaffoMinTopUp(data.waffo_min_topup || 1); + const enableAllScaleTopUp = data.enable_allscale_topup || false; + setEnableAllScaleTopUp(enableAllScaleTopUp); + setAllScaleMinTopUp(data.allscale_min_topup || 1); setMinTopUp(minTopUpValue); setTopUpCount(minTopUpValue); @@ -513,6 +717,9 @@ const TopUp = () => { // 初始化显示实付金额 getAmount(minTopUpValue); + if (enableAllScaleTopUp) { + getAllScaleAmount(minTopUpValue); + } } catch (e) { setPayMethods([]); } @@ -570,6 +777,14 @@ const TopUp = () => { showSuccess(t('邀请链接已复制到剪切板')); }; + // Resume any pending AllScale payment on mount; clear polling on unmount + useEffect(() => { + resumePendingAllScalePayment(); + return () => { + clearAllScalePolling(); + }; + }, [resumePendingAllScalePayment, clearAllScalePolling]); + // URL 参数自动打开账单弹窗(支付回跳时触发) useEffect(() => { if (searchParams.get('show_history') === 'true') { @@ -697,6 +912,10 @@ const TopUp = () => { const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discountedAmount = preset.value * priceRatio * discount; setAmount(discountedAmount); + + if (enableAllScaleTopUp) { + getAllScaleAmount(preset.value); + } }; // 格式化大数字显示 @@ -749,6 +968,7 @@ const TopUp = () => { visible={openHistory} onCancel={handleHistoryCancel} t={t} + reloadKey={historyReloadKey} /> {/* Creem 充值确认模态框 */} @@ -791,6 +1011,10 @@ const TopUp = () => { enableWaffoTopUp={enableWaffoTopUp} waffoTopUp={waffoTopUp} waffoPayMethods={waffoPayMethods} + enableAllScaleTopUp={enableAllScaleTopUp} + allScaleTopUp={allScaleTopUp} + allScaleAmount={allScaleAmount} + getAllScaleAmount={getAllScaleAmount} presetAmounts={presetAmounts} selectedPreset={selectedPreset} selectPresetAmount={selectPresetAmount} diff --git a/web/src/components/topup/modals/TopupHistoryModal.jsx b/web/src/components/topup/modals/TopupHistoryModal.jsx index 9659e9291f3e..3edb1ebfe5cc 100644 --- a/web/src/components/topup/modals/TopupHistoryModal.jsx +++ b/web/src/components/topup/modals/TopupHistoryModal.jsx @@ -35,6 +35,7 @@ import { import { Coins } from 'lucide-react'; import { IconSearch } from '@douyinfe/semi-icons'; import { API, timestamp2string } from '../../../helpers'; +import { getCurrencyConfig } from '../../../helpers/render'; import { isAdmin } from '../../../helpers/utils'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; const { Text } = Typography; @@ -52,11 +53,12 @@ const PAYMENT_METHOD_MAP = { stripe: 'Stripe', creem: 'Creem', waffo: 'Waffo', + allscale: 'AllScale USDT 结账', alipay: '支付宝', wxpay: '微信', }; -const TopupHistoryModal = ({ visible, onCancel, t }) => { +const TopupHistoryModal = ({ visible, onCancel, t, reloadKey }) => { const [loading, setLoading] = useState(false); const [topups, setTopups] = useState([]); const [total, setTotal] = useState(0); @@ -94,6 +96,13 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { } }, [visible, page, pageSize, keyword]); + // Reload when parent signals a payment was completed (e.g. polling success) + useEffect(() => { + if (visible && reloadKey > 0) { + loadTopups(page, pageSize); + } + }, [reloadKey]); + const handlePageChange = (currentPage) => { setPage(currentPage); }; @@ -197,7 +206,10 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { title: t('支付金额'), dataIndex: 'money', key: 'money', - render: (money) => ¥{money.toFixed(2)}, + render: (money) => { + const { symbol } = getCurrencyConfig(); + return {symbol}{money.toFixed(2)}; + }, }, { title: t('状态'), diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e392379e9455..aff9d8f00946 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1481,6 +1481,7 @@ "支付渠道": "Payment Channels", "支付设置": "Payment Settings", "支付请求失败": "Payment request failed", + "无法获取支付金额,请稍后重试": "Unable to fetch payment amount, please try again later", "支付金额": "Payment Amount", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", "支持 JSONPath,如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email", @@ -3344,9 +3345,36 @@ "补全倍率 {{completionRatio}}": "Completion ratio {{completionRatio}}", "输入价格:{{symbol}}{{price}} / 1M tokens": "Input Price: {{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "Output Price {{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens", + "输出价格:{{symbol}}{{price}} / 1M tokens": "Output price: {{symbol}}{{price}} / 1M tokens", + "输出价格:{{symbol}}{{total}} / 1M tokens": "Output price: {{symbol}}{{total}} / 1M tokens", "例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$", - "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching." + "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching.", + "AllScale USDT 设置": "AllScale USDT Settings", + "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。": "AllScale is a super app focused on stablecoin payments, offering stablecoin acquiring and various payment products.", + "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。": "Get your API Key and API Secret from the AllScale dashboard, and set the webhook callback URL to {your_domain}/api/allscale/webhook.", + "启用 AllScale": "Enable AllScale", + "默认 https://openapi.allscale.io,无需修改": "Default https://openapi.allscale.io, no change needed", + "单价 (USD / 配额单位)": "Unit Price (USD / quota unit)", + "每个配额单位对应的 USD 金额,默认为 1": "USD amount per quota unit, default 1", + "最低充值金额 (USD)": "Minimum Top-up Amount (USD)", + "最低支付金额(美元),默认 $1": "Minimum payment amount in USD, default $1", + "启用 AllScale 时,API Key 不能为空": "API Key is required when AllScale is enabled", + "启用 AllScale 时,API Secret 不能为空": "API Secret is required when AllScale is enabled", + "AllScale 官方站点": "AllScale Official Site", + "AllScale API 密钥": "AllScale API Key", + "AllScale API 密钥(私有)": "AllScale API Secret", + "使用 USDT 充值": "Pay with USDT", + "充值成功": "Top-up successful", + "支付处理中,请稍后在充值账单中查看结果": "Payment is being processed, please check your top-up history later for the result", + "支付已取消": "Payment cancelled", + "支付被拒绝": "Payment rejected", + "支付金额不足": "Insufficient payment amount", + "更新 AllScale USDT 设置": "Update AllScale USDT Settings", + "货币汇率(每 1 美元等于多少货币单位)": "Currency Rate (units per 1 USD)", + "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD": "Defaults to 1 (USD). Enter 10 to mean 10 currency units = 1 USD", + "AllScale USDT 结账": "AllScale USDT Checkout", + "AllScale Webhook ID": "AllScale Webhook ID", + "AllScale Webhook ID(可选,不填则跳过验证)": "AllScale Webhook ID (optional, skips verification if blank)", + "启用 AllScale 时,Webhook ID 不能为空": "Webhook ID is required when AllScale is enabled" } } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 6ff22ab56639..f732cc8d468d 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1473,6 +1473,7 @@ "支付渠道": "Canaux de paiement", "支付设置": "Paiement", "支付请求失败": "Échec de la demande de paiement", + "无法获取支付金额,请稍后重试": "Impossible d'obtenir le montant du paiement, veuillez réessayer plus tard", "支付金额": "Montant payé", "支持 Ctrl+V 粘贴图片": "Supporte Ctrl+V pour coller l'image", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Prend en charge le code de vérification TOTP à 6 chiffres ou le code de sauvegarde à 8 chiffres, peut être configuré ou consulté dans `Paramètres personnels - Paramètres de sécurité - Paramètres d'authentification à deux facteurs`.", @@ -3308,6 +3309,29 @@ "输入价格:{{symbol}}{{price}} / 1M tokens": "Prix d'entrée : {{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "Prix de sortie {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Prix de sortie : {{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens" + "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens", + "AllScale USDT 设置": "Paramètres AllScale USDT", + "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。": "AllScale est une super app axée sur les paiements en stablecoin, offrant l'acquisition en stablecoin et divers produits de paiement.", + "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。": "Obtenez votre clé API et votre secret API depuis le tableau de bord AllScale, et configurez l'URL de rappel webhook sur {your_domain}/api/allscale/webhook.", + "启用 AllScale": "Activer AllScale", + "默认 https://openapi.allscale.io,无需修改": "Par défaut https://openapi.allscale.io, aucune modification nécessaire", + "单价 (USD / 配额单位)": "Prix unitaire (USD / unité de quota)", + "每个配额单位对应的 USD 金额,默认为 1": "Montant en USD par unité de quota, par défaut 1", + "最低充值金额 (USD)": "Montant minimum de recharge (USD)", + "最低支付金额(美元),默认 $1": "Montant minimum de paiement en USD, par défaut 1 $", + "启用 AllScale 时,API Key 不能为空": "La clé API est requise lorsque AllScale est activé", + "启用 AllScale 时,API Secret 不能为空": "Le secret API est requis lorsque AllScale est activé", + "AllScale 官方站点": "Site officiel AllScale", + "AllScale API 密钥": "Clé API AllScale", + "AllScale API 密钥(私有)": "Secret API AllScale", + "使用 USDT 充值": "Payer avec USDT", + "充值成功": "Recharge réussie", + "支付处理中,请稍后在充值账单中查看结果": "Le paiement est en cours de traitement, veuillez consulter l'historique de recharge plus tard", + "支付已取消": "Paiement annulé", + "支付被拒绝": "Paiement refusé", + "支付金额不足": "Montant de paiement insuffisant", + "更新 AllScale USDT 设置": "Mettre à jour les paramètres AllScale USDT", + "货币汇率(每 1 美元等于多少货币单位)": "Taux de change (unités par 1 USD)", + "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD": "Par défaut 1 (USD). Entrez 10 pour signifier 10 unités de devise = 1 USD" } } diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index b2a59fc4e817..3cde32c150ed 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -1456,6 +1456,7 @@ "支付渠道": "決済チャネル", "支付设置": "決済設定", "支付请求失败": "決済リクエストに失敗しました", + "无法获取支付金额,请稍后重试": "支払い金額を取得できませんでした。後でもう一度お試しください", "支付金额": "決済金額", "支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "6桁のTOTP認証コードまたは8桁のバックアップコードに対応しています。`アカウント設定 - セキュリティ設定 - 2要素認証設定`で設定または確認できます。", @@ -3289,6 +3290,29 @@ "输入价格:{{symbol}}{{price}} / 1M tokens": "入力価格:{{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "補完料金 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "補完料金:{{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens" + "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens", + "AllScale USDT 设置": "AllScale USDT 設定", + "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。": "AllScaleはステーブルコイン決済に特化したsuper appで、ステーブルコイン収納および各種決済製品を提供します。", + "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。": "AllScaleダッシュボードからAPIキーとAPIシークレットを取得し、WebhookコールバックURLを{your_domain}/api/allscale/webhookに設定してください。", + "启用 AllScale": "AllScaleを有効にする", + "默认 https://openapi.allscale.io,无需修改": "デフォルト https://openapi.allscale.io、変更不要", + "单价 (USD / 配额单位)": "単価(USD / クォータ単位)", + "每个配额单位对应的 USD 金额,默认为 1": "クォータ単位あたりのUSD金額、デフォルト1", + "最低充值金额 (USD)": "最低チャージ金額(USD)", + "最低支付金额(美元),默认 $1": "最低支払い金額(USD)、デフォルト$1", + "启用 AllScale 时,API Key 不能为空": "AllScale有効時はAPIキーが必須です", + "启用 AllScale 时,API Secret 不能为空": "AllScale有効時はAPIシークレットが必須です", + "AllScale 官方站点": "AllScale 公式サイト", + "AllScale API 密钥": "AllScale APIキー", + "AllScale API 密钥(私有)": "AllScale APIシークレット", + "使用 USDT 充值": "USDTでチャージ", + "充值成功": "チャージ成功", + "支付处理中,请稍后在充值账单中查看结果": "決済処理中です。チャージ履歴を後ほどご確認ください", + "支付已取消": "支払いがキャンセルされました", + "支付被拒绝": "支払いが拒否されました", + "支付金额不足": "支払い金額が不足しています", + "更新 AllScale USDT 设置": "AllScale USDT設定を更新", + "货币汇率(每 1 美元等于多少货币单位)": "通貨レート(1 USD あたりの通貨単位数)", + "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD": "デフォルトは 1(USD)。10 と入力すると 10 通貨単位 = 1 USD を意味します" } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index ccf102425e13..87bd0f0a04ca 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1485,6 +1485,7 @@ "支付渠道": "Платежные каналы", "支付设置": "Настройки оплаты", "支付请求失败": "Запрос на оплату не удался", + "无法获取支付金额,请稍后重试": "Не удалось получить сумму платежа, попробуйте позже", "支付金额": "Сумма оплаты", "支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Поддерживает 6-значные TOTP коды подтверждения или 8-значные резервные коды, можно настроить или просмотреть в `Личные настройки-Настройки безопасности-Настройки двухфакторной аутентификации`.", @@ -3322,6 +3323,30 @@ "输入价格:{{symbol}}{{price}} / 1M tokens": "Цена ввода: {{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "Цена вывода {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Цена вывода: {{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens" + "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens", + "AllScale USDT 设置": "Настройки AllScale USDT", + "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。": "AllScale — это супер-приложение, специализирующееся на платежах в стейблкоинах, предоставляющее эквайринг стейблкоинов и различные платёжные продукты.", + "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。": "Получите API Key и API Secret в панели управления AllScale и укажите URL обратного вызова вебхука: {your_domain}/api/allscale/webhook.", + "启用 AllScale": "Включить AllScale", + "默认 https://openapi.allscale.io,无需修改": "По умолчанию https://openapi.allscale.io, изменения не требуются", + "单价 (USD / 配额单位)": "Цена за единицу (USD / единица квоты)", + "每个配额单位对应的 USD 金额,默认为 1": "Сумма в USD за единицу квоты, по умолчанию 1", + "最低充值金额 (USD)": "Минимальная сумма пополнения (USD)", + "最低支付金额(美元),默认 $1": "Минимальная сумма платежа в USD, по умолчанию $1", + "启用 AllScale 时,API Key 不能为空": "При включённом AllScale требуется API Key", + "启用 AllScale 时,API Secret 不能为空": "При включённом AllScale требуется API Secret", + "AllScale 官方站点": "Официальный сайт AllScale", + "AllScale API 密钥": "API Key AllScale", + "AllScale API 密钥(私有)": "API Secret AllScale", + "使用 USDT 充值": "Оплатить через USDT", + "充值成功": "Пополнение выполнено", + "支付处理中,请稍后在充值账单中查看结果": "Платёж обрабатывается, проверьте историю пополнений позже", + "支付已取消": "Платёж отменён", + "支付被拒绝": "Платёж отклонён", + "支付金额不足": "Недостаточная сумма платежа", + "更新 AllScale USDT 设置": "Обновить настройки AllScale USDT", + "货币汇率(每 1 美元等于多少货币单位)": "Курс валюты (единиц за 1 USD)", + "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD": "По умолчанию 1 (USD). Введите 10, чтобы означало 10 единиц валюты = 1 USD", + "AllScale USDT 结账": "Оплата через AllScale USDT" } } diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 5c9e1e8c0976..0b985f2ba272 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -1457,6 +1457,7 @@ "支付渠道": "Kênh thanh toán", "支付设置": "Cài đặt thanh toán", "支付请求失败": "Yêu cầu thanh toán thất bại", + "无法获取支付金额,请稍后重试": "Không thể lấy số tiền thanh toán, vui lòng thử lại sau", "支付金额": "Số tiền thanh toán", "支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Hỗ trợ mã xác minh TOTP 6 chữ số hoặc mã dự phòng 8 chữ số, có thể được cấu hình hoặc xem trong `Cài đặt cá nhân - Cài đặt bảo mật - Cài đặt xác thực hai yếu tố`.", @@ -3859,6 +3860,29 @@ "补全倍率 {{completionRatio}}": "Tỷ lệ hoàn thành {{completionRatio}}", "输出价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu ra {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu ra: {{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens" + "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens", + "AllScale USDT 设置": "Cài đặt AllScale USDT", + "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。": "AllScale là một super app tập trung vào thanh toán stablecoin, cung cấp dịch vụ thu tiền stablecoin và các sản phẩm thanh toán đa dạng.", + "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。": "Lấy API Key và API Secret từ bảng điều khiển AllScale và đặt URL callback webhook thành {your_domain}/api/allscale/webhook.", + "启用 AllScale": "Bật AllScale", + "默认 https://openapi.allscale.io,无需修改": "Mặc định https://openapi.allscale.io, không cần thay đổi", + "单价 (USD / 配额单位)": "Đơn giá (USD / đơn vị hạn ngạch)", + "每个配额单位对应的 USD 金额,默认为 1": "Số tiền USD mỗi đơn vị hạn ngạch, mặc định 1", + "最低充值金额 (USD)": "Số tiền nạp tối thiểu (USD)", + "最低支付金额(美元),默认 $1": "Số tiền thanh toán tối thiểu bằng USD, mặc định $1", + "启用 AllScale 时,API Key 不能为空": "API Key là bắt buộc khi AllScale được bật", + "启用 AllScale 时,API Secret 不能为空": "API Secret là bắt buộc khi AllScale được bật", + "AllScale 官方站点": "Trang web chính thức AllScale", + "AllScale API 密钥": "API Key AllScale", + "AllScale API 密钥(私有)": "API Secret AllScale", + "使用 USDT 充值": "Thanh toán bằng USDT", + "充值成功": "Nạp tiền thành công", + "支付处理中,请稍后在充值账单中查看结果": "Thanh toán đang được xử lý, vui lòng kiểm tra lịch sử nạp tiền sau", + "支付已取消": "Thanh toán đã bị huỷ", + "支付被拒绝": "Thanh toán bị từ chối", + "支付金额不足": "Số tiền thanh toán không đủ", + "更新 AllScale USDT 设置": "Cập nhật cài đặt AllScale USDT", + "货币汇率(每 1 美元等于多少货币单位)": "Tỷ giá tiền tệ (đơn vị trên 1 USD)", + "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD": "Mặc định là 1 (USD). Nhập 10 nghĩa là 10 đơn vị tiền tệ = 1 USD" } } diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 6b754ccde302..2b770fd60f50 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1169,6 +1169,7 @@ "支付方式": "支付方式", "支付设置": "支付设置", "支付请求失败": "支付请求失败", + "无法获取支付金额,请稍后重试": "无法获取支付金额,请稍后重试", "支付金额": "支付金额", "支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。", @@ -2956,6 +2957,29 @@ "输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens" + "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens", + "AllScale USDT 设置": "AllScale USDT 设置", + "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。": "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。", + "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。": "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。", + "启用 AllScale": "启用 AllScale", + "默认 https://openapi.allscale.io,无需修改": "默认 https://openapi.allscale.io,无需修改", + "单价 (USD / 配额单位)": "单价 (USD / 配额单位)", + "每个配额单位对应的 USD 金额,默认为 1": "每个配额单位对应的 USD 金额,默认为 1", + "最低充值金额 (USD)": "最低充值金额 (USD)", + "最低支付金额(美元),默认 $1": "最低支付金额(美元),默认 $1", + "启用 AllScale 时,API Key 不能为空": "启用 AllScale 时,API Key 不能为空", + "启用 AllScale 时,API Secret 不能为空": "启用 AllScale 时,API Secret 不能为空", + "AllScale 官方站点": "AllScale 官方站点", + "AllScale API 密钥": "AllScale API 密钥", + "AllScale API 密钥(私有)": "AllScale API 密钥(私有)", + "使用 USDT 充值": "使用 USDT 充值", + "充值成功": "充值成功", + "支付处理中,请稍后在充值账单中查看结果": "支付处理中,请稍后在充值账单中查看结果", + "支付已取消": "支付已取消", + "支付被拒绝": "支付被拒绝", + "支付金额不足": "支付金额不足", + "更新 AllScale USDT 设置": "更新 AllScale USDT 设置", + "货币汇率(每 1 美元等于多少货币单位)": "货币汇率(每 1 美元等于多少货币单位)", + "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD": "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD" } } diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 4e2f63564a24..dd05fad9e563 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -1172,6 +1172,7 @@ "支付方式": "支付方式", "支付设置": "支付設定", "支付请求失败": "支付請求失敗", + "无法获取支付金额,请稍后重试": "無法取得支付金額,請稍後重試", "支付金额": "支付金額", "支持 Ctrl+V 粘贴图片": "支援 Ctrl+V 貼上圖片", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支援6位TOTP驗證碼或8位備用碼,可到`個人設定-安全設定-兩步驗證設定`設定或查看。", @@ -2973,6 +2974,29 @@ "输入价格:{{symbol}}{{price}} / 1M tokens": "輸入價格:{{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens", - "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens" + "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens", + "AllScale USDT 设置": "AllScale USDT 設定", + "AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。": "AllScale是一個專注穩定幣支付的super app,提供穩定幣收單及各類支付產品。", + "请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。": "請在 AllScale 後台獲取 API Key 和 API Secret,並在回調地址中填寫 {your_domain}/api/allscale/webhook。", + "启用 AllScale": "啟用 AllScale", + "默认 https://openapi.allscale.io,无需修改": "預設 https://openapi.allscale.io,無需修改", + "单价 (USD / 配额单位)": "單價 (USD / 配額單位)", + "每个配额单位对应的 USD 金额,默认为 1": "每個配額單位對應的 USD 金額,預設為 1", + "最低充值金额 (USD)": "最低儲值金額 (USD)", + "最低支付金额(美元),默认 $1": "最低支付金額(美元),預設 $1", + "启用 AllScale 时,API Key 不能为空": "啟用 AllScale 時,API Key 不能為空", + "启用 AllScale 时,API Secret 不能为空": "啟用 AllScale 時,API Secret 不能為空", + "AllScale 官方站点": "AllScale 官方站點", + "AllScale API 密钥": "AllScale API 密鑰", + "AllScale API 密钥(私有)": "AllScale API 密鑰(私有)", + "使用 USDT 充值": "使用 USDT 儲值", + "充值成功": "儲值成功", + "支付处理中,请稍后在充值账单中查看结果": "支付處理中,請稍後在儲值帳單中查看結果", + "支付已取消": "支付已取消", + "支付被拒绝": "支付被拒絕", + "支付金额不足": "支付金額不足", + "更新 AllScale USDT 设置": "更新 AllScale USDT 設定", + "货币汇率(每 1 美元等于多少货币单位)": "貨幣匯率(每 1 美元等於多少貨幣單位)", + "默认为 1,即使用美元。例如填写 10 表示 10 个货币单位 = 1 USD": "預設為 1,即使用美元。例如填寫 10 表示 10 個貨幣單位 = 1 USD" } } diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayAllScale.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayAllScale.jsx new file mode 100644 index 000000000000..00b18bebec90 --- /dev/null +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayAllScale.jsx @@ -0,0 +1,274 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState, useRef } from 'react'; +import { + Banner, + Button, + Form, + Row, + Col, + Typography, + Spin, +} from '@douyinfe/semi-ui'; +import { API, showError, showSuccess } from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +export default function SettingsPaymentGatewayAllScale(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + AllScaleEnabled: false, + AllScaleApiKey: '', + AllScaleApiSecret: '', + AllScaleWebhookID: '', + AllScaleBaseURL: 'https://openapi.allscale.io', + AllScaleUnitPrice: 1, + AllScaleMinTopUp: 1, + }); + const [originInputs, setOriginInputs] = useState({}); + const [hasExistingApiKey, setHasExistingApiKey] = useState(false); + const [hasExistingApiSecret, setHasExistingApiSecret] = useState(false); + const formApiRef = useRef(null); + + useEffect(() => { + if (props.options && formApiRef.current) { + const currentInputs = { + AllScaleEnabled: + props.options.AllScaleEnabled === 'true' || + props.options.AllScaleEnabled === true, + AllScaleApiKey: '', + AllScaleApiSecret: '', + AllScaleWebhookID: props.options.AllScaleWebhookID || '', + AllScaleBaseURL: + props.options.AllScaleBaseURL || 'https://openapi.allscale.io', + AllScaleUnitPrice: parseFloat(props.options.AllScaleUnitPrice) || 1, + AllScaleMinTopUp: parseFloat(props.options.AllScaleMinTopUp) || 1, + }; + setHasExistingApiKey(props.options.AllScaleApiKeySet === 'true'); + setHasExistingApiSecret(props.options.AllScaleApiSecretSet === 'true'); + setInputs(currentInputs); + setOriginInputs({ ...currentInputs }); + formApiRef.current.setValues(currentInputs); + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitAllScaleSetting = async () => { + if (inputs.AllScaleEnabled) { + if (!inputs.AllScaleApiKey.trim() && !hasExistingApiKey) { + showError(t('启用 AllScale 时,API Key 不能为空')); + return; + } + if (!inputs.AllScaleApiSecret.trim() && !hasExistingApiSecret) { + showError(t('启用 AllScale 时,API Secret 不能为空')); + return; + } + } + setLoading(true); + try { + // Save credentials and config first, then enable — so AllScale is never + // active in a broken/unconfigured state if a partial write fails. + const credentialOptions = [ + { + key: 'AllScaleBaseURL', + value: inputs.AllScaleBaseURL || 'https://openapi.allscale.io', + }, + { + key: 'AllScaleUnitPrice', + value: String(parseFloat(inputs.AllScaleUnitPrice) || 1), + }, + { + key: 'AllScaleMinTopUp', + value: String(parseFloat(inputs.AllScaleMinTopUp) || 1), + }, + { + key: 'AllScaleWebhookID', + value: inputs.AllScaleWebhookID.trim(), + }, + ]; + + if (inputs.AllScaleApiKey.trim()) { + credentialOptions.push({ key: 'AllScaleApiKey', value: inputs.AllScaleApiKey }); + } + if (inputs.AllScaleApiSecret.trim()) { + credentialOptions.push({ + key: 'AllScaleApiSecret', + value: inputs.AllScaleApiSecret, + }); + } + + const credentialResults = await Promise.all( + credentialOptions.map((opt) => + API.put('/api/option/', { key: opt.key, value: opt.value }), + ), + ); + + const enableResult = await API.put('/api/option/', { + key: 'AllScaleEnabled', + value: inputs.AllScaleEnabled ? 'true' : 'false', + }); + + const results = [...credentialResults, enableResult]; + + const errorResults = results.filter((res) => !res.data.success); + if (errorResults.length > 0) { + errorResults.forEach((res) => showError(res.data.message)); + } else { + showSuccess(t('更新成功')); + if (inputs.AllScaleApiKey.trim()) { + setHasExistingApiKey(true); + formApiRef.current?.setValue('AllScaleApiKey', ''); + setInputs((prev) => ({ ...prev, AllScaleApiKey: '' })); + } + if (inputs.AllScaleApiSecret.trim()) { + setHasExistingApiSecret(true); + formApiRef.current?.setValue('AllScaleApiSecret', ''); + setInputs((prev) => ({ ...prev, AllScaleApiSecret: '' })); + } + setOriginInputs({ ...inputs, AllScaleApiKey: '', AllScaleApiSecret: '' }); + props.refresh?.(); + } + } catch { + showError(t('更新失败')); + } + setLoading(false); + }; + + const serverAddress = props.options?.ServerAddress?.trim(); + const webhookCallbackUrl = serverAddress + ? `${serverAddress}/api/allscale/webhook` + : '{your_domain}/api/allscale/webhook'; + const bannerDescription = t( + '请在 AllScale 后台获取 API Key 和 API Secret,并在回调地址中填写 {your_domain}/api/allscale/webhook。', + ).replace('{your_domain}/api/allscale/webhook', webhookCallbackUrl); + + return ( + + (formApiRef.current = api)} + > + + + {t('AllScale是一个专注稳定币支付的super app,提供稳定币收单及各类支付产品。')} + + {' '} + {t('AllScale 官方站点')} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ ); +}