Commit f995a868 by Seefs Committed by GitHub

Merge pull request #4089 from seefs001/feature/waffo-pay

rafactor: payment
parent 5b9dcf1b
...@@ -27,6 +27,15 @@ var completionRatioMetaOptionKeys = []string{ ...@@ -27,6 +27,15 @@ var completionRatioMetaOptionKeys = []string{
"AudioCompletionRatio", "AudioCompletionRatio",
} }
func isVisiblePublicKeyOption(key string) bool {
switch key {
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
return true
default:
return false
}
}
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) { func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" { if strings.TrimSpace(raw) == "" {
return return
...@@ -66,11 +75,12 @@ func GetOptions(c *gin.Context) { ...@@ -66,11 +75,12 @@ func GetOptions(c *gin.Context) {
common.OptionMapRWMutex.Lock() common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap { for k, v := range common.OptionMap {
value := common.Interface2String(v) value := common.Interface2String(v)
if strings.HasSuffix(k, "Token") || isSensitiveKey := strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Secret") ||
strings.HasSuffix(k, "Key") || strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") || strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key") { strings.HasSuffix(k, "api_key")
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
continue continue
} }
options = append(options, &model.Option{ options = append(options, &model.Option{
......
package controller
import (
"strings"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
func isStripeTopUpEnabled() bool {
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
strings.TrimSpace(setting.StripePriceId) != ""
}
func isStripeWebhookConfigured() bool {
return strings.TrimSpace(setting.StripeWebhookSecret) != ""
}
func isStripeWebhookEnabled() bool {
return isStripeTopUpEnabled()
}
func isCreemTopUpEnabled() bool {
products := strings.TrimSpace(setting.CreemProducts)
return strings.TrimSpace(setting.CreemApiKey) != "" &&
products != "" &&
products != "[]"
}
func isCreemWebhookConfigured() bool {
return strings.TrimSpace(setting.CreemWebhookSecret) != ""
}
func isCreemWebhookEnabled() bool {
return isCreemTopUpEnabled() && isCreemWebhookConfigured()
}
func isWaffoTopUpEnabled() bool {
if !setting.WaffoEnabled {
return false
}
return isWaffoWebhookConfigured()
}
func isWaffoWebhookConfigured() bool {
if setting.WaffoSandbox {
return strings.TrimSpace(setting.WaffoSandboxApiKey) != "" &&
strings.TrimSpace(setting.WaffoSandboxPrivateKey) != "" &&
strings.TrimSpace(setting.WaffoSandboxPublicCert) != ""
}
return strings.TrimSpace(setting.WaffoApiKey) != "" &&
strings.TrimSpace(setting.WaffoPrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPublicCert) != ""
}
func isWaffoWebhookEnabled() bool {
return isWaffoTopUpEnabled()
}
func isWaffoPancakeTopUpEnabled() bool {
if !setting.WaffoPancakeEnabled {
return false
}
return isWaffoPancakeWebhookConfigured() &&
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
}
func isWaffoPancakeWebhookConfigured() bool {
currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
if setting.WaffoPancakeSandbox {
currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
}
return currentWebhookKey != ""
}
func isWaffoPancakeWebhookEnabled() bool {
return isWaffoPancakeTopUpEnabled()
}
func isEpayTopUpEnabled() bool {
return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
}
func isEpayWebhookConfigured() bool {
return strings.TrimSpace(operation_setting.PayAddress) != "" &&
strings.TrimSpace(operation_setting.EpayId) != "" &&
strings.TrimSpace(operation_setting.EpayKey) != ""
}
func isEpayWebhookEnabled() bool {
return isEpayTopUpEnabled()
}
package controller
import (
"testing"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/require"
)
func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalAPISecret := setting.StripeApiSecret
originalWebhookSecret := setting.StripeWebhookSecret
originalPriceID := setting.StripePriceId
t.Cleanup(func() {
setting.StripeApiSecret = originalAPISecret
setting.StripeWebhookSecret = originalWebhookSecret
setting.StripePriceId = originalPriceID
})
setting.StripeWebhookSecret = ""
setting.StripeApiSecret = "sk_test_123"
setting.StripePriceId = "price_123"
require.False(t, isStripeWebhookEnabled())
setting.StripeWebhookSecret = "whsec_test"
require.True(t, isStripeWebhookEnabled())
setting.StripePriceId = ""
require.False(t, isStripeWebhookEnabled())
}
func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalAPIKey := setting.CreemApiKey
originalProducts := setting.CreemProducts
originalWebhookSecret := setting.CreemWebhookSecret
t.Cleanup(func() {
setting.CreemApiKey = originalAPIKey
setting.CreemProducts = originalProducts
setting.CreemWebhookSecret = originalWebhookSecret
})
setting.CreemWebhookSecret = ""
setting.CreemApiKey = "creem_api_key"
setting.CreemProducts = `[{"productId":"prod_123"}]`
require.False(t, isCreemWebhookEnabled())
setting.CreemWebhookSecret = "creem_secret"
require.True(t, isCreemWebhookEnabled())
setting.CreemProducts = "[]"
require.False(t, isCreemWebhookEnabled())
}
func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalEnabled := setting.WaffoEnabled
originalSandbox := setting.WaffoSandbox
originalAPIKey := setting.WaffoApiKey
originalPrivateKey := setting.WaffoPrivateKey
originalPublicCert := setting.WaffoPublicCert
originalSandboxAPIKey := setting.WaffoSandboxApiKey
originalSandboxPrivateKey := setting.WaffoSandboxPrivateKey
originalSandboxPublicCert := setting.WaffoSandboxPublicCert
t.Cleanup(func() {
setting.WaffoEnabled = originalEnabled
setting.WaffoSandbox = originalSandbox
setting.WaffoApiKey = originalAPIKey
setting.WaffoPrivateKey = originalPrivateKey
setting.WaffoPublicCert = originalPublicCert
setting.WaffoSandboxApiKey = originalSandboxAPIKey
setting.WaffoSandboxPrivateKey = originalSandboxPrivateKey
setting.WaffoSandboxPublicCert = originalSandboxPublicCert
})
setting.WaffoEnabled = true
setting.WaffoSandbox = false
setting.WaffoApiKey = ""
setting.WaffoPrivateKey = "private"
setting.WaffoPublicCert = "public"
require.False(t, isWaffoWebhookEnabled())
setting.WaffoApiKey = "api"
require.True(t, isWaffoWebhookEnabled())
setting.WaffoEnabled = false
require.False(t, isWaffoWebhookEnabled())
setting.WaffoEnabled = true
setting.WaffoSandbox = true
setting.WaffoSandboxApiKey = ""
setting.WaffoSandboxPrivateKey = "sandbox_private"
setting.WaffoSandboxPublicCert = "sandbox_public"
require.False(t, isWaffoWebhookEnabled())
setting.WaffoSandboxApiKey = "sandbox_api"
require.True(t, isWaffoWebhookEnabled())
}
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalEnabled := setting.WaffoPancakeEnabled
originalSandbox := setting.WaffoPancakeSandbox
originalMerchantID := setting.WaffoPancakeMerchantID
originalPrivateKey := setting.WaffoPancakePrivateKey
originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
originalStoreID := setting.WaffoPancakeStoreID
originalProductID := setting.WaffoPancakeProductID
t.Cleanup(func() {
setting.WaffoPancakeEnabled = originalEnabled
setting.WaffoPancakeSandbox = originalSandbox
setting.WaffoPancakeMerchantID = originalMerchantID
setting.WaffoPancakePrivateKey = originalPrivateKey
setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
setting.WaffoPancakeStoreID = originalStoreID
setting.WaffoPancakeProductID = originalProductID
})
setting.WaffoPancakeEnabled = true
setting.WaffoPancakeSandbox = false
setting.WaffoPancakeMerchantID = "merchant"
setting.WaffoPancakePrivateKey = "private"
setting.WaffoPancakeStoreID = "store"
setting.WaffoPancakeProductID = "product"
setting.WaffoPancakeWebhookPublicKey = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookPublicKey = "public"
require.True(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = false
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeEnabled = true
setting.WaffoPancakeSandbox = true
setting.WaffoPancakeWebhookTestKey = ""
require.False(t, isWaffoPancakeWebhookEnabled())
setting.WaffoPancakeWebhookTestKey = "test_public"
require.True(t, isWaffoPancakeWebhookEnabled())
}
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
originalPayAddress := operation_setting.PayAddress
originalEpayID := operation_setting.EpayId
originalEpayKey := operation_setting.EpayKey
originalPayMethods := operation_setting.PayMethods
t.Cleanup(func() {
operation_setting.PayAddress = originalPayAddress
operation_setting.EpayId = originalEpayID
operation_setting.EpayKey = originalEpayKey
operation_setting.PayMethods = originalPayMethods
})
operation_setting.PayAddress = "https://pay.example.com"
operation_setting.EpayId = "epay_id"
operation_setting.EpayKey = ""
operation_setting.PayMethods = []map[string]string{{"type": "alipay"}}
require.False(t, isEpayWebhookEnabled())
operation_setting.EpayKey = "epay_key"
require.True(t, isEpayWebhookEnabled())
operation_setting.PayMethods = nil
require.False(t, isEpayWebhookEnabled())
}
...@@ -2,11 +2,13 @@ package controller ...@@ -2,11 +2,13 @@ package controller
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"log" "net/http"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
...@@ -24,14 +26,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) { ...@@ -24,14 +26,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
// Keep body for debugging consistency (like RequestCreemPay) // Keep body for debugging consistency (like RequestCreemPay)
bodyBytes, err := io.ReadAll(c.Request.Body) bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil { if err != nil {
log.Printf("read subscription creem pay req body err: %v", err) logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付请求读取失败 error=%q", err.Error()))
c.JSON(200, gin.H{"message": "error", "data": "read query error"}) c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
return return
} }
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return return
} }
...@@ -85,12 +87,12 @@ func SubscriptionRequestCreemPay(c *gin.Context) { ...@@ -85,12 +87,12 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
PlanId: plan.Id, PlanId: plan.Id,
Money: plan.PriceAmount, Money: plan.PriceAmount,
TradeNo: referenceId, TradeNo: referenceId,
PaymentMethod: PaymentMethodCreem, PaymentMethod: model.PaymentMethodCreem,
CreateTime: time.Now().Unix(), CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending, Status: common.TopUpStatusPending,
} }
if err := order.Insert(); err != nil { if err := order.Insert(); err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return return
} }
...@@ -112,14 +114,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) { ...@@ -112,14 +114,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
Quota: 0, Quota: 0,
} }
checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username) checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, product, user.Email, user.Username)
if err != nil { if err != nil {
log.Printf("获取Creem支付链接失败: %v", err) logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return return
} }
c.JSON(200, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "success", "message": "success",
"data": gin.H{ "data": gin.H{
"checkout_url": checkoutUrl, "checkout_url": checkoutUrl,
......
...@@ -104,7 +104,7 @@ func SubscriptionRequestEpay(c *gin.Context) { ...@@ -104,7 +104,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
ReturnUrl: returnUrl, ReturnUrl: returnUrl,
}) })
if err != nil { if err != nil {
_ = model.ExpireSubscriptionOrder(tradeNo) _ = model.ExpireSubscriptionOrder(tradeNo, req.PaymentMethod)
common.ApiErrorMsg(c, "拉起支付失败") common.ApiErrorMsg(c, "拉起支付失败")
return return
} }
...@@ -156,7 +156,7 @@ func SubscriptionEpayNotify(c *gin.Context) { ...@@ -156,7 +156,7 @@ func SubscriptionEpayNotify(c *gin.Context) {
LockOrder(verifyInfo.ServiceTradeNo) LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil { if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), verifyInfo.Type); err != nil {
_, _ = c.Writer.Write([]byte("fail")) _, _ = c.Writer.Write([]byte("fail"))
return return
} }
...@@ -205,7 +205,7 @@ func SubscriptionEpayReturn(c *gin.Context) { ...@@ -205,7 +205,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
if verifyInfo.TradeStatus == epay.StatusTradeSuccess { if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
LockOrder(verifyInfo.ServiceTradeNo) LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil { if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), verifyInfo.Type); err != nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail") c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
return return
} }
......
...@@ -2,12 +2,12 @@ package controller ...@@ -2,12 +2,12 @@ package controller
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/system_setting" "github.com/QuantumNous/new-api/setting/system_setting"
...@@ -78,7 +78,7 @@ func SubscriptionRequestStripePay(c *gin.Context) { ...@@ -78,7 +78,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId) payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
if err != nil { if err != nil {
log.Println("获取Stripe Checkout支付链接失败", err) logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return return
} }
...@@ -88,7 +88,7 @@ func SubscriptionRequestStripePay(c *gin.Context) { ...@@ -88,7 +88,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
PlanId: plan.Id, PlanId: plan.Id,
Money: plan.PriceAmount, Money: plan.PriceAmount,
TradeNo: referenceId, TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe, PaymentMethod: model.PaymentMethodStripe,
CreateTime: time.Now().Unix(), CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending, Status: common.TopUpStatusPending,
} }
......
package controller
import (
"testing"
"github.com/QuantumNous/new-api/model"
)
func TestIsNonEpayPaymentMethodForEpayCallback(t *testing.T) {
testCases := []struct {
name string
paymentMethod string
expectedBlocked bool
}{
{name: "stripe", paymentMethod: model.PaymentMethodStripe, expectedBlocked: true},
{name: "creem", paymentMethod: model.PaymentMethodCreem, expectedBlocked: true},
{name: "waffo", paymentMethod: model.PaymentMethodWaffo, expectedBlocked: true},
{name: "waffo pancake", paymentMethod: model.PaymentMethodWaffoPancake, expectedBlocked: true},
{name: "alipay", paymentMethod: "alipay", expectedBlocked: false},
{name: "wxpay", paymentMethod: "wxpay", expectedBlocked: false},
{name: "custom epay type", paymentMethod: "custom1", expectedBlocked: false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if actual := isNonEpayPaymentMethodForEpayCallback(tc.paymentMethod); actual != tc.expectedBlocked {
t.Fatalf("expected blocked=%v, got %v for payment method %q", tc.expectedBlocked, actual, tc.paymentMethod)
}
})
}
}
package controller
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"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/shopspring/decimal"
"github.com/thanhpk/randstr"
)
type WaffoPancakePayRequest struct {
Amount int64 `json:"amount"`
}
func RequestWaffoPancakeAmount(c *gin.Context) {
var req WaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getWaffoPancakePayMoney(req.Amount, group)
if payMoney <= 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "success", "data": fmt.Sprintf("%.2f", payMoney)})
}
func getWaffoPancakePayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount)
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit))
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok && ds > 0 {
discount = ds
}
payMoney := dAmount.
Mul(decimal.NewFromFloat(setting.WaffoPancakeUnitPrice)).
Mul(decimal.NewFromFloat(topupGroupRatio)).
Mul(decimal.NewFromFloat(discount))
return payMoney.InexactFloat64()
}
func normalizeWaffoPancakeTopUpAmount(amount int64) int64 {
if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens {
return amount
}
normalized := decimal.NewFromInt(amount).
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
IntPart()
if normalized < 1 {
return 1
}
return normalized
}
func formatWaffoPancakeAmount(payMoney float64) string {
return decimal.NewFromFloat(payMoney).StringFixed(2)
}
func getWaffoPancakeBuyerEmail(user *model.User) string {
if user != nil && strings.TrimSpace(user.Email) != "" {
return user.Email
}
if user != nil {
return fmt.Sprintf("%d@new-api.local", user.Id)
}
return ""
}
func getWaffoPancakeReturnURL() string {
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
return setting.WaffoPancakeReturnURL
}
return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true"
}
func RequestWaffoPancakePay(c *gin.Context) {
if !setting.WaffoPancakeEnabled {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"})
return
}
currentWebhookKey := setting.WaffoPancakeWebhookPublicKey
if setting.WaffoPancakeSandbox {
currentWebhookKey = setting.WaffoPancakeWebhookTestKey
}
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
strings.TrimSpace(currentWebhookKey) == "" ||
strings.TrimSpace(setting.WaffoPancakeStoreID) == "" ||
strings.TrimSpace(setting.WaffoPancakeProductID) == "" {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
return
}
var req WaffoPancakePayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
return
}
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getWaffoPancakePayMoney(req.Amount, group)
if payMoney < 0.01 {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
topUp := &model.TopUp{
UserId: id,
Amount: normalizeWaffoPancakeTopUpAmount(req.Amount),
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
expiresInSeconds := 45 * 60
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
StoreID: setting.WaffoPancakeStoreID,
ProductID: setting.WaffoPancakeProductID,
ProductType: "onetime",
Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
Amount: formatWaffoPancakeAmount(payMoney),
TaxIncluded: false,
TaxCategory: "saas",
},
BuyerEmail: getWaffoPancakeBuyerEmail(user),
SuccessURL: getWaffoPancakeReturnURL(),
ExpiresInSeconds: &expiresInSeconds,
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": session.CheckoutURL,
"session_id": session.SessionID,
"expires_at": session.ExpiresAt,
"order_id": tradeNo,
},
})
}
func WaffoPancakeWebhook(c *gin.Context) {
if !isWaffoPancakeWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.String(http.StatusForbidden, "webhook disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.String(http.StatusBadRequest, "bad request")
return
}
signature := c.GetHeader("X-Waffo-Signature")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature)
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
c.String(http.StatusUnauthorized, "invalid signature")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
if event.NormalizedEventType() != "order.completed" {
c.String(http.StatusOK, "OK")
return
}
tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error()))
c.String(http.StatusOK, "OK")
return
}
LockOrder(tradeNo)
defer UnlockOrder(tradeNo)
if err := model.RechargeWaffoPancake(tradeNo); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
c.String(http.StatusInternalServerError, "retry")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
c.String(http.StatusOK, "OK")
}
package controller
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/require"
)
func TestFormatWaffoPancakeAmount_UsesDisplayPriceString(t *testing.T) {
testCases := []struct {
name string
amount float64
expected string
}{
{name: "whole amount", amount: 29, expected: "29.00"},
{name: "decimal amount", amount: 29.9, expected: "29.90"},
{name: "round half up to cents", amount: 29.999, expected: "30.00"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, formatWaffoPancakeAmount(tc.amount))
})
}
}
func TestGetWaffoPancakePayMoney(t *testing.T) {
originalUnitPrice := setting.WaffoPancakeUnitPrice
originalQuotaDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
originalDiscounts := make(map[int]float64, len(operation_setting.GetPaymentSetting().AmountDiscount))
for k, v := range operation_setting.GetPaymentSetting().AmountDiscount {
originalDiscounts[k] = v
}
originalTopupGroupRatio := common.TopupGroupRatio2JSONString()
t.Cleanup(func() {
setting.WaffoPancakeUnitPrice = originalUnitPrice
operation_setting.GetGeneralSetting().QuotaDisplayType = originalQuotaDisplayType
operation_setting.GetPaymentSetting().AmountDiscount = originalDiscounts
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(originalTopupGroupRatio))
})
setting.WaffoPancakeUnitPrice = 2.5
operation_setting.GetPaymentSetting().AmountDiscount = map[int]float64{
10: 0.8,
int(common.QuotaPerUnit * 3): 0.5,
20: 0,
}
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"default":1,"vip":1.2}`))
testCases := []struct {
name string
amount int64
group string
quotaDisplayType string
expected float64
}{
{
name: "currency display applies unit price group ratio and discount",
amount: 10,
group: "vip",
quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
expected: 24,
},
{
name: "tokens display converts quota to display units before pricing",
amount: int64(common.QuotaPerUnit * 3),
group: "vip",
quotaDisplayType: operation_setting.QuotaDisplayTypeTokens,
expected: 4.5,
},
{
name: "non-positive discount falls back to no discount",
amount: 20,
group: "default",
quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
expected: 50,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
operation_setting.GetGeneralSetting().QuotaDisplayType = tc.quotaDisplayType
actual := getWaffoPancakePayMoney(tc.amount, tc.group)
require.InDelta(t, tc.expected, actual, 0.000001)
})
}
}
...@@ -5,6 +5,28 @@ import ( ...@@ -5,6 +5,28 @@ import (
"strconv" "strconv"
) )
type StringValue string
func (s *StringValue) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = StringValue(str)
return nil
}
var raw json.Number
if err := json.Unmarshal(data, &raw); err == nil {
*s = StringValue(raw.String())
return nil
}
return json.Unmarshal(data, &str)
}
func (s StringValue) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
type IntValue int type IntValue int
func (i *IntValue) UnmarshalJSON(b []byte) error { func (i *IntValue) UnmarshalJSON(b []byte) error {
......
...@@ -106,6 +106,18 @@ func InitOptionMap() { ...@@ -106,6 +106,18 @@ func InitOptionMap() {
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64) common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp) common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString() common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled)
common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox)
common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey
common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey
common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
...@@ -407,6 +419,30 @@ func updateOptionMap(key string, value string) (err error) { ...@@ -407,6 +419,30 @@ func updateOptionMap(key string, value string) (err error) {
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64) setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoMinTopUp": case "WaffoMinTopUp":
setting.WaffoMinTopUp, _ = strconv.Atoi(value) setting.WaffoMinTopUp, _ = strconv.Atoi(value)
case "WaffoPancakeEnabled":
setting.WaffoPancakeEnabled = value == "true"
case "WaffoPancakeSandbox":
setting.WaffoPancakeSandbox = value == "true"
case "WaffoPancakeMerchantID":
setting.WaffoPancakeMerchantID = value
case "WaffoPancakePrivateKey":
setting.WaffoPancakePrivateKey = value
case "WaffoPancakeWebhookPublicKey":
setting.WaffoPancakeWebhookPublicKey = value
case "WaffoPancakeWebhookTestKey":
setting.WaffoPancakeWebhookTestKey = value
case "WaffoPancakeStoreID":
setting.WaffoPancakeStoreID = value
case "WaffoPancakeProductID":
setting.WaffoPancakeProductID = value
case "WaffoPancakeReturnURL":
setting.WaffoPancakeReturnURL = value
case "WaffoPancakeCurrency":
setting.WaffoPancakeCurrency = value
case "WaffoPancakeUnitPrice":
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoPancakeMinTopUp":
setting.WaffoPancakeMinTopUp, _ = strconv.Atoi(value)
case "TopupGroupRatio": case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value) err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId": case "GitHubClientId":
......
package model
import (
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) {
t.Helper()
user := &User{
Id: id,
Username: "payment_guard_user",
Status: common.UserStatusEnabled,
Quota: quota,
}
require.NoError(t, DB.Create(user).Error)
}
func insertSubscriptionPlanForPaymentGuardTest(t *testing.T, id int) *SubscriptionPlan {
t.Helper()
plan := &SubscriptionPlan{
Id: id,
Title: "Guard Plan",
PriceAmount: 9.99,
Currency: "USD",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
Enabled: true,
TotalAmount: 1000,
}
require.NoError(t, DB.Create(plan).Error)
return plan
}
func insertSubscriptionOrderForPaymentGuardTest(t *testing.T, tradeNo string, userID int, planID int, paymentMethod string) {
t.Helper()
order := &SubscriptionOrder{
UserId: userID,
PlanId: planID,
Money: 9.99,
TradeNo: tradeNo,
PaymentMethod: paymentMethod,
Status: common.TopUpStatusPending,
CreateTime: time.Now().Unix(),
}
require.NoError(t, order.Insert())
}
func insertTopUpForPaymentGuardTest(t *testing.T, tradeNo string, userID int, paymentMethod string) {
t.Helper()
topUp := &TopUp{
UserId: userID,
Amount: 2,
Money: 9.99,
TradeNo: tradeNo,
PaymentMethod: paymentMethod,
Status: common.TopUpStatusPending,
CreateTime: time.Now().Unix(),
}
require.NoError(t, topUp.Insert())
}
func getTopUpStatusForPaymentGuardTest(t *testing.T, tradeNo string) string {
t.Helper()
topUp := GetTopUpByTradeNo(tradeNo)
require.NotNil(t, topUp)
return topUp.Status
}
func countUserSubscriptionsForPaymentGuardTest(t *testing.T, userID int) int64 {
t.Helper()
var count int64
require.NoError(t, DB.Model(&UserSubscription{}).Where("user_id = ?", userID).Count(&count).Error)
return count
}
func getUserQuotaForPaymentGuardTest(t *testing.T, userID int) int {
t.Helper()
var user User
require.NoError(t, DB.Select("quota").Where("id = ?", userID).First(&user).Error)
return user.Quota
}
func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 101, 0)
insertTopUpForPaymentGuardTest(t, "waffo-pancake-guard", 101, PaymentMethodStripe)
err := RechargeWaffoPancake("waffo-pancake-guard")
require.Error(t, err)
topUp := GetTopUpByTradeNo("waffo-pancake-guard")
require.NotNil(t, topUp)
assert.Equal(t, common.TopUpStatusPending, topUp.Status)
assert.Equal(t, 0, getUserQuotaForPaymentGuardTest(t, 101))
}
func TestUpdatePendingTopUpStatus_RejectsMismatchedPaymentMethod(t *testing.T) {
testCases := []struct {
name string
tradeNo string
storedPaymentMethod string
expectedPaymentMethod string
targetStatus string
}{
{
name: "stripe expire",
tradeNo: "stripe-expire-guard",
storedPaymentMethod: PaymentMethodCreem,
expectedPaymentMethod: PaymentMethodStripe,
targetStatus: common.TopUpStatusExpired,
},
{
name: "waffo failed",
tradeNo: "waffo-failed-guard",
storedPaymentMethod: PaymentMethodStripe,
expectedPaymentMethod: PaymentMethodWaffo,
targetStatus: common.TopUpStatusFailed,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 150, 0)
insertTopUpForPaymentGuardTest(t, tc.tradeNo, 150, tc.storedPaymentMethod)
err := UpdatePendingTopUpStatus(tc.tradeNo, tc.expectedPaymentMethod, tc.targetStatus)
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, tc.tradeNo))
})
}
}
func TestCompleteSubscriptionOrder_RejectsMismatchedPaymentMethod(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 202, 0)
plan := insertSubscriptionPlanForPaymentGuardTest(t, 301)
insertSubscriptionOrderForPaymentGuardTest(t, "sub-guard-order", 202, plan.Id, PaymentMethodStripe)
err := CompleteSubscriptionOrder("sub-guard-order", `{"provider":"epay"}`, "alipay")
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
order := GetSubscriptionOrderByTradeNo("sub-guard-order")
require.NotNil(t, order)
assert.Equal(t, common.TopUpStatusPending, order.Status)
assert.Zero(t, countUserSubscriptionsForPaymentGuardTest(t, 202))
topUp := GetTopUpByTradeNo("sub-guard-order")
assert.Nil(t, topUp)
}
func TestExpireSubscriptionOrder_RejectsMismatchedPaymentMethod(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 303, 0)
plan := insertSubscriptionPlanForPaymentGuardTest(t, 401)
insertSubscriptionOrderForPaymentGuardTest(t, "sub-expire-guard", 303, plan.Id, PaymentMethodStripe)
err := ExpireSubscriptionOrder("sub-expire-guard", PaymentMethodCreem)
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
order := GetSubscriptionOrderByTradeNo("sub-expire-guard")
require.NotNil(t, order)
assert.Equal(t, common.TopUpStatusPending, order.Status)
}
...@@ -505,7 +505,7 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio ...@@ -505,7 +505,7 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
} }
// Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan. // Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan.
func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error { func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedPaymentMethod string) error {
if tradeNo == "" { if tradeNo == "" {
return errors.New("tradeNo is empty") return errors.New("tradeNo is empty")
} }
...@@ -523,6 +523,9 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error { ...@@ -523,6 +523,9 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
return ErrSubscriptionOrderNotFound return ErrSubscriptionOrderNotFound
} }
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if order.Status == common.TopUpStatusSuccess { if order.Status == common.TopUpStatusSuccess {
return nil return nil
} }
...@@ -596,6 +599,8 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error { ...@@ -596,6 +599,8 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
topup.Money = order.Money topup.Money = order.Money
if topup.PaymentMethod == "" { if topup.PaymentMethod == "" {
topup.PaymentMethod = order.PaymentMethod topup.PaymentMethod = order.PaymentMethod
} else if topup.PaymentMethod != order.PaymentMethod {
return ErrPaymentMethodMismatch
} }
if topup.CreateTime == 0 { if topup.CreateTime == 0 {
topup.CreateTime = order.CreateTime topup.CreateTime = order.CreateTime
...@@ -605,7 +610,7 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error { ...@@ -605,7 +610,7 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
return tx.Save(&topup).Error return tx.Save(&topup).Error
} }
func ExpireSubscriptionOrder(tradeNo string) error { func ExpireSubscriptionOrder(tradeNo string, expectedPaymentMethod string) error {
if tradeNo == "" { if tradeNo == "" {
return errors.New("tradeNo is empty") return errors.New("tradeNo is empty")
} }
...@@ -618,6 +623,9 @@ func ExpireSubscriptionOrder(tradeNo string) error { ...@@ -618,6 +623,9 @@ func ExpireSubscriptionOrder(tradeNo string) error {
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
return ErrSubscriptionOrderNotFound return ErrSubscriptionOrderNotFound
} }
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if order.Status != common.TopUpStatusPending { if order.Status != common.TopUpStatusPending {
return nil return nil
} }
......
...@@ -33,7 +33,17 @@ func TestMain(m *testing.M) { ...@@ -33,7 +33,17 @@ func TestMain(m *testing.M) {
} }
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&Task{}, &User{}, &Token{}, &Log{}, &Channel{}); err != nil { if err := db.AutoMigrate(
&Task{},
&User{},
&Token{},
&Log{},
&Channel{},
&TopUp{},
&SubscriptionPlan{},
&SubscriptionOrder{},
&UserSubscription{},
); err != nil {
panic("failed to migrate: " + err.Error()) panic("failed to migrate: " + err.Error())
} }
...@@ -48,6 +58,10 @@ func truncateTables(t *testing.T) { ...@@ -48,6 +58,10 @@ func truncateTables(t *testing.T) {
DB.Exec("DELETE FROM tokens") DB.Exec("DELETE FROM tokens")
DB.Exec("DELETE FROM logs") DB.Exec("DELETE FROM logs")
DB.Exec("DELETE FROM channels") DB.Exec("DELETE FROM channels")
DB.Exec("DELETE FROM top_ups")
DB.Exec("DELETE FROM subscription_orders")
DB.Exec("DELETE FROM subscription_plans")
DB.Exec("DELETE FROM user_subscriptions")
}) })
} }
......
...@@ -23,7 +23,18 @@ type TopUp struct { ...@@ -23,7 +23,18 @@ type TopUp struct {
Status string `json:"status"` Status string `json:"status"`
} }
var ErrPaymentMethodMismatch = errors.New("payment method mismatch") const (
PaymentMethodStripe = "stripe"
PaymentMethodCreem = "creem"
PaymentMethodWaffo = "waffo"
PaymentMethodWaffoPancake = "waffo_pancake"
)
var (
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
ErrTopUpNotFound = errors.New("topup not found")
ErrTopUpStatusInvalid = errors.New("topup status invalid")
)
func (topUp *TopUp) Insert() error { func (topUp *TopUp) Insert() error {
var err error var err error
...@@ -57,6 +68,33 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp { ...@@ -57,6 +68,33 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
return topUp return topUp
} }
func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentMethod string, targetStatus string) error {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
return DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{}
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return ErrTopUpNotFound
}
if expectedPaymentMethod != "" && topUp.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return ErrTopUpStatusInvalid
}
topUp.Status = targetStatus
return tx.Save(topUp).Error
})
}
func Recharge(referenceId string, customerId string, callerIp string) (err error) { func Recharge(referenceId string, customerId string, callerIp string) (err error) {
if referenceId == "" { if referenceId == "" {
return errors.New("未提供支付单号") return errors.New("未提供支付单号")
...@@ -76,7 +114,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error ...@@ -76,7 +114,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
return errors.New("充值订单不存在") return errors.New("充值订单不存在")
} }
if topUp.PaymentMethod != "stripe" { if topUp.PaymentMethod != PaymentMethodStripe {
return ErrPaymentMethodMismatch return ErrPaymentMethodMismatch
} }
...@@ -105,7 +143,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error ...@@ -105,7 +143,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
return errors.New("充值失败,请稍后重试") return errors.New("充值失败,请稍后重试")
} }
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, "stripe") RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
return nil return nil
} }
...@@ -302,7 +340,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { ...@@ -302,7 +340,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
// 计算应充值额度: // 计算应充值额度:
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit // - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
if topUp.PaymentMethod == "stripe" { if topUp.PaymentMethod == PaymentMethodStripe {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart()) quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
} else { } else {
...@@ -359,7 +397,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string ...@@ -359,7 +397,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值订单不存在") return errors.New("充值订单不存在")
} }
if topUp.PaymentMethod != "creem" { if topUp.PaymentMethod != PaymentMethodCreem {
return ErrPaymentMethodMismatch return ErrPaymentMethodMismatch
} }
...@@ -410,7 +448,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string ...@@ -410,7 +448,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值失败,请稍后重试") return errors.New("充值失败,请稍后重试")
} }
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, "creem") RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem)
return nil return nil
} }
...@@ -434,7 +472,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { ...@@ -434,7 +472,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return errors.New("充值订单不存在") return errors.New("充值订单不存在")
} }
if topUp.PaymentMethod != "waffo" { if topUp.PaymentMethod != PaymentMethodWaffo {
return ErrPaymentMethodMismatch return ErrPaymentMethodMismatch
} }
...@@ -472,7 +510,68 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { ...@@ -472,7 +510,68 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
} }
if quotaToAdd > 0 { if quotaToAdd > 0 {
RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, "waffo") RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo)
}
return nil
}
func RechargeWaffoPancake(tradeNo string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
var quotaToAdd int
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != PaymentMethodWaffoPancake {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
return nil
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
quotaToAdd = int(decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart())
if quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
return nil
})
if err != nil {
common.SysError("waffo pancake topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
if quotaToAdd > 0 {
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
} }
return nil return nil
......
...@@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
// Universal secure verification routes // Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
...@@ -90,7 +91,10 @@ func SetApiRouter(router *gin.Engine) { ...@@ -90,7 +91,10 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount)
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay) selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
//selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
//selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting) selfRoute.PUT("/setting", controller.UpdateUserSetting)
......
...@@ -42,6 +42,7 @@ func TestMain(m *testing.M) { ...@@ -42,6 +42,7 @@ func TestMain(m *testing.M) {
&model.Token{}, &model.Token{},
&model.Log{}, &model.Log{},
&model.Channel{}, &model.Channel{},
&model.TopUp{},
&model.UserSubscription{}, &model.UserSubscription{},
); err != nil { ); err != nil {
panic("failed to migrate: " + err.Error()) panic("failed to migrate: " + err.Error())
...@@ -62,6 +63,7 @@ func truncate(t *testing.T) { ...@@ -62,6 +63,7 @@ func truncate(t *testing.T) {
model.DB.Exec("DELETE FROM tokens") model.DB.Exec("DELETE FROM tokens")
model.DB.Exec("DELETE FROM logs") model.DB.Exec("DELETE FROM logs")
model.DB.Exec("DELETE FROM channels") model.DB.Exec("DELETE FROM channels")
model.DB.Exec("DELETE FROM top_ups")
model.DB.Exec("DELETE FROM user_subscriptions") model.DB.Exec("DELETE FROM user_subscriptions")
}) })
} }
......
package service
import (
"fmt"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
t.Helper()
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
model.DB = db
model.LOG_DB = db
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}))
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func TestWaffoPancakeCreateSessionResponseParsesDocumentedPayload(t *testing.T) {
var result waffoPancakeCreateSessionResponse
err := common.Unmarshal([]byte(`{
"data": {
"sessionId": "cs_550e8400-e29b-41d4-a716-446655440000",
"checkoutUrl": "https://checkout.waffo.ai/my-store-abc123/checkout/cs_550e8400-e29b-41d4-a716-446655440000",
"expiresAt": "2026-01-22T10:30:00.000Z"
}
}`), &result)
require.NoError(t, err)
require.NotNil(t, result.Data)
require.Equal(t, "cs_550e8400-e29b-41d4-a716-446655440000", result.Data.SessionID)
require.Empty(t, result.Data.OrderID)
}
func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
topUp := &model.TopUp{
UserId: 1,
Amount: 10,
Money: 29,
TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
PaymentMethod: model.PaymentMethodWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(topUp).Error)
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
Data: waffoPancakeWebhookData{
OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
},
})
require.NoError(t, err)
require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo)
}
func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) {
db := setupWaffoPancakeTestDB(t)
user := &model.User{
Id: 42,
Email: "buyer@example.com",
Username: "buyer",
Status: common.UserStatusEnabled,
}
require.NoError(t, db.Create(user).Error)
topUp := &model.TopUp{
UserId: user.Id,
Amount: 10,
Money: 29,
TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
PaymentMethod: model.PaymentMethodWaffoPancake,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
require.NoError(t, db.Create(topUp).Error)
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
Data: waffoPancakeWebhookData{
OrderID: "ORD_unknown",
BuyerEmail: user.Email,
Amount: "29.00",
},
})
require.Error(t, err)
require.Empty(t, tradeNo)
}
func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) {
originalSandbox := setting.WaffoPancakeSandbox
t.Cleanup(func() {
setting.WaffoPancakeSandbox = originalSandbox
})
testCases := []struct {
name string
payload string
expected string
sandbox bool
}{
{
name: "test mode",
payload: `{"mode":"test"}`,
expected: "test",
},
{
name: "prod mode",
payload: `{"mode":"prod"}`,
expected: "prod",
},
{
name: "missing mode falls back to sandbox",
payload: `{}`,
expected: "test",
sandbox: true,
},
{
name: "invalid mode falls back to prod",
payload: `{"mode":"staging"}`,
expected: "prod",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
setting.WaffoPancakeSandbox = tc.sandbox
environment := resolveWaffoPancakeWebhookEnvironment(tc.payload)
require.Equal(t, tc.expected, environment)
})
}
}
package setting
var (
WaffoPancakeEnabled bool
WaffoPancakeSandbox bool
WaffoPancakeMerchantID string
WaffoPancakePrivateKey string
WaffoPancakeWebhookPublicKey string
WaffoPancakeWebhookTestKey string
WaffoPancakeStoreID string
WaffoPancakeProductID string
WaffoPancakeReturnURL string
WaffoPancakeCurrency string = "USD"
WaffoPancakeUnitPrice float64 = 1.0
WaffoPancakeMinTopUp int = 1
)
...@@ -18,12 +18,13 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,12 +18,13 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui'; import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment'; import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway'; import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo'; import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
import SettingsPaymentGatewayWaffoPancake from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake';
import { API, showError, toBoolean } from '../../helpers'; import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
...@@ -48,6 +49,17 @@ const PaymentSetting = () => { ...@@ -48,6 +49,17 @@ const PaymentSetting = () => {
StripeUnitPrice: 8.0, StripeUnitPrice: 8.0,
StripeMinTopUp: 1, StripeMinTopUp: 1,
StripePromotionCodesEnabled: false, StripePromotionCodesEnabled: false,
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
}); });
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
...@@ -96,8 +108,21 @@ const PaymentSetting = () => { ...@@ -96,8 +108,21 @@ const PaymentSetting = () => {
case 'MinTopUp': case 'MinTopUp':
case 'StripeUnitPrice': case 'StripeUnitPrice':
case 'StripeMinTopUp': case 'StripeMinTopUp':
case 'WaffoPancakeUnitPrice':
case 'WaffoPancakeMinTopUp':
newInputs[item.key] = parseFloat(item.value); newInputs[item.key] = parseFloat(item.value);
break; break;
case 'WaffoPancakeMerchantID':
case 'WaffoPancakePrivateKey':
case 'WaffoPancakeStoreID':
case 'WaffoPancakeProductID':
case 'WaffoPancakeReturnURL':
case 'WaffoPancakeCurrency':
newInputs[item.key] = item.value;
break;
case 'WaffoPancakeSandbox':
newInputs[item.key] = toBoolean(item.value);
break;
default: default:
if (item.key.endsWith('Enabled')) { if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value); newInputs[item.key] = toBoolean(item.value);
...@@ -108,7 +133,7 @@ const PaymentSetting = () => { ...@@ -108,7 +133,7 @@ const PaymentSetting = () => {
} }
}); });
setInputs(newInputs); setInputs((prev) => ({ ...prev, ...newInputs }));
} else { } else {
showError(t(message)); showError(t(message));
} }
...@@ -133,19 +158,54 @@ const PaymentSetting = () => { ...@@ -133,19 +158,54 @@ const PaymentSetting = () => {
<> <>
<Spin spinning={loading} size='large'> <Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}> <Card style={{ marginTop: '10px' }}>
<SettingsGeneralPayment options={inputs} refresh={onRefresh} /> <Tabs
</Card> type='card'
<Card style={{ marginTop: '10px' }}> defaultActiveKey='general'
<SettingsPaymentGateway options={inputs} refresh={onRefresh} /> contentStyle={{ paddingTop: 24 }}
</Card> >
<Card style={{ marginTop: '10px' }}> <Tabs.TabPane tab={t('通用设置')} itemKey='general'>
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} /> <SettingsGeneralPayment
</Card> options={inputs}
<Card style={{ marginTop: '10px' }}> refresh={onRefresh}
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} /> hideSectionTitle
</Card> />
<Card style={{ marginTop: '10px' }}> </Tabs.TabPane>
<SettingsPaymentGatewayWaffo options={inputs} refresh={onRefresh} /> <Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
<SettingsPaymentGateway
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
<SettingsPaymentGatewayStripe
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
<SettingsPaymentGatewayCreem
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
<SettingsPaymentGatewayWaffo
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
{/* <SettingsPaymentGatewayWaffoPancake*/}
{/* options={inputs}*/}
{/* refresh={onRefresh}*/}
{/* hideSectionTitle*/}
{/* />*/}
{/*</Tabs.TabPane>*/}
</Tabs>
</Card> </Card>
</Spin> </Spin>
</> </>
......
...@@ -21,7 +21,6 @@ import React, { useEffect, useRef, useState } from 'react'; ...@@ -21,7 +21,6 @@ import React, { useEffect, useRef, useState } from 'react';
import { import {
Avatar, Avatar,
Typography, Typography,
Tag,
Card, Card,
Button, Button,
Banner, Banner,
...@@ -88,8 +87,7 @@ const RechargeCard = ({ ...@@ -88,8 +87,7 @@ const RechargeCard = ({
topupInfo, topupInfo,
onOpenHistory, onOpenHistory,
enableWaffoTopUp, enableWaffoTopUp,
waffoTopUp, enableWaffoPancakeTopUp,
waffoPayMethods,
subscriptionLoading = false, subscriptionLoading = false,
subscriptionPlans = [], subscriptionPlans = [],
billingPreference, billingPreference,
...@@ -105,6 +103,7 @@ const RechargeCard = ({ ...@@ -105,6 +103,7 @@ const RechargeCard = ({
const [activeTab, setActiveTab] = useState('topup'); const [activeTab, setActiveTab] = useState('topup');
const shouldShowSubscription = const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0; !subscriptionLoading && subscriptionPlans.length > 0;
const regularPayMethods = payMethods || [];
useEffect(() => { useEffect(() => {
if (initialTabSetRef.current) return; if (initialTabSetRef.current) return;
...@@ -227,19 +226,31 @@ const RechargeCard = ({ ...@@ -227,19 +226,31 @@ const RechargeCard = ({
<div className='py-8 flex justify-center'> <div className='py-8 flex justify-center'>
<Spin size='large' /> <Spin size='large' />
</div> </div>
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? ( ) : enableOnlineTopUp ||
enableStripeTopUp ||
enableCreemTopUp ||
enableWaffoTopUp ||
enableWaffoPancakeTopUp ? (
<Form <Form
getFormApi={(api) => (onlineFormApiRef.current = api)} getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }} initValues={{ topUpCount: topUpCount }}
> >
<div className='space-y-6'> <div className='space-y-6'>
{(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && ( {(enableOnlineTopUp ||
enableStripeTopUp ||
enableWaffoTopUp ||
enableWaffoPancakeTopUp) && (
<Row gutter={12}> <Row gutter={12}>
<Col xs={24} sm={24} md={24} lg={10} xl={10}> <Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.InputNumber <Form.InputNumber
field='topUpCount' field='topUpCount'
label={t('充值数量')} label={t('充值数量')}
disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWaffoTopUp} disabled={
!enableOnlineTopUp &&
!enableStripeTopUp &&
!enableWaffoTopUp &&
!enableWaffoPancakeTopUp
}
placeholder={ placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp) t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
} }
...@@ -291,16 +302,27 @@ const RechargeCard = ({ ...@@ -291,16 +302,27 @@ const RechargeCard = ({
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</Col> </Col>
{payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && ( {regularPayMethods.length > 0 && (
<Col xs={24} sm={24} md={24} lg={14} xl={14}> <Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}> <Form.Slot label={t('选择支付方式')}>
<Space wrap> <Space wrap>
{payMethods.filter(m => m.type !== 'waffo').map((payMethod) => { {regularPayMethods.map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0; const minTopupVal =
Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe'; const isStripe = payMethod.type === 'stripe';
const isWaffo =
typeof payMethod.type === 'string' &&
payMethod.type.startsWith('waffo:');
const isWaffoPancake =
payMethod.type === 'waffo_pancake';
const disabled = const disabled =
(!enableOnlineTopUp && !isStripe) || (!enableOnlineTopUp &&
!isStripe &&
!isWaffo &&
!isWaffoPancake) ||
(!enableStripeTopUp && isStripe) || (!enableStripeTopUp && isStripe) ||
(!enableWaffoTopUp && isWaffo) ||
(!enableWaffoPancakeTopUp && isWaffoPancake) ||
minTopupVal > Number(topUpCount || 0); minTopupVal > Number(topUpCount || 0);
const buttonEl = ( const buttonEl = (
...@@ -320,6 +342,21 @@ const RechargeCard = ({ ...@@ -320,6 +342,21 @@ const RechargeCard = ({
<SiWechat size={18} color='#07C160' /> <SiWechat size={18} color='#07C160' />
) : payMethod.type === 'stripe' ? ( ) : payMethod.type === 'stripe' ? (
<SiStripe size={18} color='#635BFF' /> <SiStripe size={18} color='#635BFF' />
) : payMethod.icon ? (
<img
src={payMethod.icon}
alt={payMethod.name}
style={{
width: 18,
height: 18,
objectFit: 'contain',
}}
/>
) : payMethod.type === 'waffo_pancake' ? (
<CreditCard
size={18}
color='var(--semi-color-primary)'
/>
) : ( ) : (
<CreditCard <CreditCard
size={18} size={18}
...@@ -388,7 +425,9 @@ const RechargeCard = ({ ...@@ -388,7 +425,9 @@ const RechargeCard = ({
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'> <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{presetAmounts.map((preset, index) => { {presetAmounts.map((preset, index) => {
const discount = const discount =
preset.discount || topupInfo?.discount?.[preset.value] || 1.0; preset.discount ||
topupInfo?.discount?.[preset.value] ||
1.0;
const originalPrice = preset.value * priceRatio; const originalPrice = preset.value * priceRatio;
const discountedPrice = originalPrice * discount; const discountedPrice = originalPrice * discount;
const hasDiscount = discount < 1.0; const hasDiscount = discount < 1.0;
...@@ -404,7 +443,7 @@ const RechargeCard = ({ ...@@ -404,7 +443,7 @@ const RechargeCard = ({
const s = JSON.parse(statusStr); const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7; usdRate = s?.usd_exchange_rate || 7;
} }
} catch (e) { } } catch (e) {}
let displayValue = preset.value; // 显示的数量 let displayValue = preset.value; // 显示的数量
let displayActualPay = actualPay; let displayActualPay = actualPay;
...@@ -455,7 +494,10 @@ const RechargeCard = ({ ...@@ -455,7 +494,10 @@ const RechargeCard = ({
{hasDiscount && ( {hasDiscount && (
<Tag style={{ marginLeft: 4 }} color='green'> <Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off') {t('折').includes('off')
? ((1 - parseFloat(discount)) * 100).toFixed(1) ? (
(1 - parseFloat(discount)) *
100
).toFixed(1)
: (discount * 10).toFixed(1)} : (discount * 10).toFixed(1)}
{t('折')} {t('折')}
</Tag> </Tag>
...@@ -482,46 +524,6 @@ const RechargeCard = ({ ...@@ -482,46 +524,6 @@ const RechargeCard = ({
</Form.Slot> </Form.Slot>
)} )}
{/* Waffo 充值区域 */}
{enableWaffoTopUp &&
waffoPayMethods &&
waffoPayMethods.length > 0 && (
<Form.Slot label={t('Waffo 充值')}>
<Space wrap>
{waffoPayMethods.map((method, index) => (
<Button
key={index}
theme='outline'
type='tertiary'
onClick={() => waffoTopUp(index)}
loading={paymentLoading}
icon={
method.icon ? (
<img
src={method.icon}
alt={method.name}
style={{
width: 36,
height: 36,
objectFit: 'contain',
}}
/>
) : (
<CreditCard
size={18}
color='var(--semi-color-text-2)'
/>
)
}
className='!rounded-lg !px-4 !py-2'
>
{method.name}
</Button>
))}
</Space>
</Form.Slot>
)}
{/* Creem 充值区域 */} {/* Creem 充值区域 */}
{enableCreemTopUp && creemProducts.length > 0 && ( {enableCreemTopUp && creemProducts.length > 0 && (
<Form.Slot label={t('Creem 充值')}> <Form.Slot label={t('Creem 充值')}>
......
...@@ -75,6 +75,8 @@ const TopUp = () => { ...@@ -75,6 +75,8 @@ const TopUp = () => {
const [enableWaffoTopUp, setEnableWaffoTopUp] = useState(false); const [enableWaffoTopUp, setEnableWaffoTopUp] = useState(false);
const [waffoPayMethods, setWaffoPayMethods] = useState([]); const [waffoPayMethods, setWaffoPayMethods] = useState([]);
const [waffoMinTopUp, setWaffoMinTopUp] = useState(1); const [waffoMinTopUp, setWaffoMinTopUp] = useState(1);
const [enableWaffoPancakeTopUp, setEnableWaffoPancakeTopUp] = useState(false);
const [waffoPancakeMinTopUp, setWaffoPancakeMinTopUp] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
...@@ -112,6 +114,39 @@ const TopUp = () => { ...@@ -112,6 +114,39 @@ const TopUp = () => {
discount: {}, discount: {},
}); });
const confirmPayMethods = [
...payMethods,
...waffoPayMethods.map((method, index) => ({
...method,
type: `waffo:${index}`,
min_topup: waffoMinTopUp,
color: method.color || 'rgba(var(--semi-primary-5), 1)',
})),
];
const getPayMethodConfig = (payment) =>
confirmPayMethods.find((method) => method.type === payment);
const getPaymentMinTopUp = (payment) => {
const configuredMinTopUp = Number(getPayMethodConfig(payment)?.min_topup);
return Number.isFinite(configuredMinTopUp) && configuredMinTopUp > 0
? configuredMinTopUp
: minTopUp;
};
const requestAmountByPayment = async (payment, value) => {
if (payment === 'stripe') {
return getStripeAmount(value);
}
if (payment === 'waffo_pancake') {
return getWaffoPancakeAmount(value);
}
if (typeof payment === 'string' && payment.startsWith('waffo:')) {
return getWaffoAmount(value);
}
return getAmount(value);
};
const topUp = async () => { const topUp = async () => {
if (redemptionCode === '') { if (redemptionCode === '') {
showInfo(t('请输入兑换码!')); showInfo(t('请输入兑换码!'));
...@@ -162,6 +197,16 @@ const TopUp = () => { ...@@ -162,6 +197,16 @@ const TopUp = () => {
showError(t('管理员未开启Stripe充值!')); showError(t('管理员未开启Stripe充值!'));
return; return;
} }
} else if (payment === 'waffo_pancake') {
if (!enableWaffoPancakeTopUp) {
showError(t('管理员未开启 Waffo Pancake 充值!'));
return;
}
} else if (payment.startsWith('waffo:')) {
if (!enableWaffoTopUp) {
showError(t('管理员未开启 Waffo 充值!'));
return;
}
} else { } else {
if (!enableOnlineTopUp) { if (!enableOnlineTopUp) {
showError(t('管理员未开启在线充值!')); showError(t('管理员未开启在线充值!'));
...@@ -172,14 +217,11 @@ const TopUp = () => { ...@@ -172,14 +217,11 @@ const TopUp = () => {
setPayWay(payment); setPayWay(payment);
setPaymentLoading(true); setPaymentLoading(true);
try { try {
if (payment === 'stripe') { const selectedMinTopUp = getPaymentMinTopUp(payment);
await getStripeAmount(); await requestAmountByPayment(payment);
} else {
await getAmount();
}
if (topUpCount < minTopUp) { if (topUpCount < selectedMinTopUp) {
showError(t('充值数量不能小于') + minTopUp); showError(t('充值数量不能小于') + selectedMinTopUp);
return; return;
} }
setOpen(true); setOpen(true);
...@@ -191,6 +233,29 @@ const TopUp = () => { ...@@ -191,6 +233,29 @@ const TopUp = () => {
}; };
const onlineTopUp = async () => { const onlineTopUp = async () => {
if (payWay === 'waffo_pancake') {
setConfirmLoading(true);
try {
await waffoPancakeTopUp();
} finally {
setOpen(false);
setConfirmLoading(false);
}
return;
}
if (payWay.startsWith('waffo:')) {
const payMethodIndex = Number(payWay.split(':')[1]);
setConfirmLoading(true);
try {
await waffoTopUp(Number.isFinite(payMethodIndex) ? payMethodIndex : 0);
} finally {
setOpen(false);
setConfirmLoading(false);
}
return;
}
if (payWay === 'stripe') { if (payWay === 'stripe') {
// Stripe 支付处理 // Stripe 支付处理
if (amount === 0) { if (amount === 0) {
...@@ -346,6 +411,96 @@ const TopUp = () => { ...@@ -346,6 +411,96 @@ const TopUp = () => {
} }
}; };
const getWaffoAmount = async (value) => {
if (value === undefined) {
value = topUpCount;
}
setAmountLoading(true);
try {
const res = await API.post('/api/user/waffo/amount', {
amount: parseInt(value),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
setAmount(parseFloat(data));
} else {
setAmount(0);
Toast.error({ content: '错误:' + data, id: 'getAmount' });
}
} else {
showError(res);
}
} catch (err) {
// amount fetch failed silently
} finally {
setAmountLoading(false);
}
};
const waffoPancakeTopUp = async () => {
const minTopUpValue = Number(waffoPancakeMinTopUp || 1);
if (topUpCount < minTopUpValue) {
showError(t('充值数量不能小于') + minTopUpValue);
return;
}
setPaymentLoading(true);
try {
const res = await API.post('/api/user/waffo-pancake/pay', {
amount: parseInt(topUpCount),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
const checkoutUrl = data?.checkout_url || '';
if (checkoutUrl) {
window.open(checkoutUrl, '_blank');
} else {
showError(t('支付请求失败'));
}
} else {
const errorMsg =
typeof data === 'string' ? data : message || t('支付请求失败');
showError(errorMsg);
}
} else {
showError(res);
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaymentLoading(false);
}
};
const getWaffoPancakeAmount = async (value) => {
if (value === undefined) {
value = topUpCount;
}
setAmountLoading(true);
try {
const res = await API.post('/api/user/waffo-pancake/amount', {
amount: parseInt(value),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
setAmount(parseFloat(data));
} else {
setAmount(0);
Toast.error({ content: '错误:' + data, id: 'getAmount' });
}
} else {
showError(res);
}
} catch (err) {
// amount fetch failed silently
} finally {
setAmountLoading(false);
}
};
const processCreemCallback = (data) => { const processCreemCallback = (data) => {
// 与 Stripe 保持一致的实现方式 // 与 Stripe 保持一致的实现方式
window.open(data.checkout_url, '_blank'); window.open(data.checkout_url, '_blank');
...@@ -481,20 +636,26 @@ const TopUp = () => { ...@@ -481,20 +636,26 @@ const TopUp = () => {
const enableStripeTopUp = data.enable_stripe_topup || false; const enableStripeTopUp = data.enable_stripe_topup || false;
const enableOnlineTopUp = data.enable_online_topup || false; const enableOnlineTopUp = data.enable_online_topup || false;
const enableCreemTopUp = data.enable_creem_topup || false; const enableCreemTopUp = data.enable_creem_topup || false;
const enableWaffoTopUp = data.enable_waffo_topup || false;
const enableWaffoPancakeTopUp =
data.enable_waffo_pancake_topup || false;
const minTopUpValue = enableOnlineTopUp const minTopUpValue = enableOnlineTopUp
? data.min_topup ? data.min_topup
: enableStripeTopUp : enableStripeTopUp
? data.stripe_min_topup ? data.stripe_min_topup
: data.enable_waffo_topup : enableWaffoTopUp
? data.waffo_min_topup ? data.waffo_min_topup
: enableWaffoPancakeTopUp
? data.waffo_pancake_min_topup
: 1; : 1;
setEnableOnlineTopUp(enableOnlineTopUp); setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp); setEnableStripeTopUp(enableStripeTopUp);
setEnableCreemTopUp(enableCreemTopUp); setEnableCreemTopUp(enableCreemTopUp);
const enableWaffoTopUp = data.enable_waffo_topup || false;
setEnableWaffoTopUp(enableWaffoTopUp); setEnableWaffoTopUp(enableWaffoTopUp);
setWaffoPayMethods(data.waffo_pay_methods || []); setWaffoPayMethods(data.waffo_pay_methods || []);
setWaffoMinTopUp(data.waffo_min_topup || 1); setWaffoMinTopUp(data.waffo_min_topup || 1);
setEnableWaffoPancakeTopUp(enableWaffoPancakeTopUp);
setWaffoPancakeMinTopUp(data.waffo_pancake_min_topup || 1);
setMinTopUp(minTopUpValue); setMinTopUp(minTopUpValue);
setTopUpCount(minTopUpValue); setTopUpCount(minTopUpValue);
...@@ -739,7 +900,7 @@ const TopUp = () => { ...@@ -739,7 +900,7 @@ const TopUp = () => {
amountLoading={amountLoading} amountLoading={amountLoading}
renderAmount={renderAmount} renderAmount={renderAmount}
payWay={payWay} payWay={payWay}
payMethods={payMethods} payMethods={confirmPayMethods}
amountNumber={amount} amountNumber={amount}
discountRate={topupInfo?.discount?.[topUpCount] || 1.0} discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
/> />
...@@ -789,8 +950,7 @@ const TopUp = () => { ...@@ -789,8 +950,7 @@ const TopUp = () => {
creemProducts={creemProducts} creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp} creemPreTopUp={creemPreTopUp}
enableWaffoTopUp={enableWaffoTopUp} enableWaffoTopUp={enableWaffoTopUp}
waffoTopUp={waffoTopUp} enableWaffoPancakeTopUp={enableWaffoPancakeTopUp}
waffoPayMethods={waffoPayMethods}
presetAmounts={presetAmounts} presetAmounts={presetAmounts}
selectedPreset={selectedPreset} selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount} selectPresetAmount={selectPresetAmount}
...@@ -804,7 +964,7 @@ const TopUp = () => { ...@@ -804,7 +964,7 @@ const TopUp = () => {
setSelectedPreset={setSelectedPreset} setSelectedPreset={setSelectedPreset}
renderAmount={renderAmount} renderAmount={renderAmount}
amountLoading={amountLoading} amountLoading={amountLoading}
payMethods={payMethods} payMethods={confirmPayMethods}
preTopUp={preTopUp} preTopUp={preTopUp}
paymentLoading={paymentLoading} paymentLoading={paymentLoading}
payWay={payWay} payWay={payWay}
......
...@@ -140,6 +140,17 @@ const PaymentConfirmModal = ({ ...@@ -140,6 +140,17 @@ const PaymentConfirmModal = ({
size={16} size={16}
color='#635BFF' color='#635BFF'
/> />
) : payMethod.icon ? (
<img
src={payMethod.icon}
alt={payMethod.name}
className='mr-2'
style={{
width: 16,
height: 16,
objectFit: 'contain',
}}
/>
) : ( ) : (
<CreditCard <CreditCard
className='mr-2' className='mr-2'
......
...@@ -14,7 +14,9 @@ ...@@ -14,7 +14,9 @@
",点击更新": ", click Update", ",点击更新": ", click Update",
"(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "", "(共 {{total}} 个)": "",
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Currently only supports Epay interface, the default callback address is the server address above!)", "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Currently only the Epay interface is supported. Configure the callback address in General Settings.",
"请确认商户和所选环境密钥一致。": "Make sure the merchant and keys for the selected environment match.",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "Make sure Merchant, Store, Product, and the keys for the selected environment match.",
"(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)", "(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)",
"(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)",
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(Input {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(Input {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
...@@ -743,6 +745,8 @@ ...@@ -743,6 +745,8 @@
"最低": "lowest", "最低": "lowest",
"最低充值数量": "", "最低充值数量": "",
"最低充值美元数量": "Minimum recharge dollar amount", "最低充值美元数量": "Minimum recharge dollar amount",
"最低充值美元数量必须大于 0": "Minimum recharge dollar amount must be greater than 0",
"留空则自动使用当前站点的默认回调地址": "Leave blank to use the default callback address of the current site",
"最后使用时间": "Last used time", "最后使用时间": "Last used time",
"最后更新": "Last Updated", "最后更新": "Last Updated",
"最后请求": "Last request", "最后请求": "Last request",
...@@ -1044,7 +1048,7 @@ ...@@ -1044,7 +1048,7 @@
"响应缺少凭据": "Response missing credentials", "响应缺少凭据": "Response missing credentials",
"响应缺少授权链接": "Response missing authorization link", "响应缺少授权链接": "Response missing authorization link",
"商品价格 ID": "Product Price ID", "商品价格 ID": "Product Price ID",
"商户 ID": "", "商户 ID": "Merchant ID",
"回答内容": "Answer Content", "回答内容": "Answer Content",
"回调 URL 填": "Callback URL Fill", "回调 URL 填": "Callback URL Fill",
"回调 URL 格式": "Callback URL format", "回调 URL 格式": "Callback URL format",
...@@ -1712,7 +1716,7 @@ ...@@ -1712,7 +1716,7 @@
"支付渠道": "Payment Channels", "支付渠道": "Payment Channels",
"支付设置": "Payment", "支付设置": "Payment",
"支付请求失败": "Payment request failed", "支付请求失败": "Payment request failed",
"支付返回地址": "", "支付返回地址": "Return URL",
"支付金额": "Payment Amount", "支付金额": "Payment Amount",
"支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images",
"支持 JSONPath,如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email", "支持 JSONPath,如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email",
...@@ -2212,7 +2216,7 @@ ...@@ -2212,7 +2216,7 @@
"永久删除您的两步验证设置": "Permanently delete your two-factor authentication settings", "永久删除您的两步验证设置": "Permanently delete your two-factor authentication settings",
"永久删除所有备用码(包括未使用的)": "Permanently delete all backup codes (including unused ones)", "永久删除所有备用码(包括未使用的)": "Permanently delete all backup codes (including unused ones)",
"汇率": "Exchange rate", "汇率": "Exchange rate",
"沙盒模式": "", "沙盒模式": "Sandbox Mode",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Sandbox RSA private key Base64 (PKCS#8 DER)", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Sandbox RSA private key Base64 (PKCS#8 DER)",
"沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Sandbox Waffo public key Base64 (X.509 DER)", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Sandbox Waffo public key Base64 (X.509 DER)",
......
...@@ -16,7 +16,9 @@ ...@@ -16,7 +16,9 @@
",点击更新": ", cliquez sur Mettre à jour", ",点击更新": ", cliquez sur Mettre à jour",
"(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "", "(共 {{total}} 个)": "",
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Actuellement, seule l'interface Epay est prise en charge, l'adresse du serveur ci-dessus est utilisée par défaut comme adresse de rappel !)", "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Seule l'interface Epay est actuellement prise en charge. Configurez l'adresse de rappel dans les paramètres généraux.",
"请确认商户和所选环境密钥一致。": "Vérifiez que le marchand et les clés de l'environnement sélectionné correspondent.",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "Vérifiez que Merchant, Store, Product et les clés de l'environnement sélectionné correspondent.",
"(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)", "(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)",
"(筛选后显示 {{count}} 条)_many": "(Affichage de {{count}} éléments après filtrage)", "(筛选后显示 {{count}} 条)_many": "(Affichage de {{count}} éléments après filtrage)",
"(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)",
...@@ -742,6 +744,8 @@ ...@@ -742,6 +744,8 @@
"最低": "Le plus bas", "最低": "Le plus bas",
"最低充值数量": "", "最低充值数量": "",
"最低充值美元数量": "Montant minimum de recharge en dollars", "最低充值美元数量": "Montant minimum de recharge en dollars",
"最低充值美元数量必须大于 0": "Le montant minimum de recharge en dollars doit être supérieur à 0",
"留空则自动使用当前站点的默认回调地址": "Laissez vide pour utiliser l'adresse de rappel par défaut du site actuel",
"最后使用时间": "Dernière utilisation", "最后使用时间": "Dernière utilisation",
"最后更新": "Last Updated", "最后更新": "Last Updated",
"最后请求": "Dernière requête", "最后请求": "Dernière requête",
...@@ -1042,7 +1046,7 @@ ...@@ -1042,7 +1046,7 @@
"响应缺少凭据": "Identifiants manquants dans la réponse", "响应缺少凭据": "Identifiants manquants dans la réponse",
"响应缺少授权链接": "Lien d'autorisation manquant dans la réponse", "响应缺少授权链接": "Lien d'autorisation manquant dans la réponse",
"商品价格 ID": "ID du prix du produit", "商品价格 ID": "ID du prix du produit",
"商户 ID": "", "商户 ID": "ID marchand",
"回答内容": "Contenu de la réponse", "回答内容": "Contenu de la réponse",
"回调 URL 填": "Remplir l'URL de rappel", "回调 URL 填": "Remplir l'URL de rappel",
"回调 URL 格式": "Format de l'URL de rappel", "回调 URL 格式": "Format de l'URL de rappel",
...@@ -1712,7 +1716,7 @@ ...@@ -1712,7 +1716,7 @@
"支付渠道": "Canaux de paiement", "支付渠道": "Canaux de paiement",
"支付设置": "Paiement", "支付设置": "Paiement",
"支付请求失败": "Échec de la demande de paiement", "支付请求失败": "Échec de la demande de paiement",
"支付返回地址": "", "支付返回地址": "URL de retour",
"支付金额": "Montant payé", "支付金额": "Montant payé",
"支持 Ctrl+V 粘贴图片": "Supporte Ctrl+V pour coller l'image", "支持 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`.", "支持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`.",
...@@ -2203,7 +2207,7 @@ ...@@ -2203,7 +2207,7 @@
"永久删除您的两步验证设置": "Supprimer définitivement vos paramètres d'authentification à deux facteurs", "永久删除您的两步验证设置": "Supprimer définitivement vos paramètres d'authentification à deux facteurs",
"永久删除所有备用码(包括未使用的)": "Supprimer définitivement tous les codes de sauvegarde (y compris ceux non utilisés)", "永久删除所有备用码(包括未使用的)": "Supprimer définitivement tous les codes de sauvegarde (y compris ceux non utilisés)",
"汇率": "Taux de change", "汇率": "Taux de change",
"沙盒模式": "", "沙盒模式": "Mode bac à sable",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Clé privée RSA Base64 (PKCS#8 DER) de sandbox", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Clé privée RSA Base64 (PKCS#8 DER) de sandbox",
"沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Clé publique Waffo Base64 (X.509 DER) de sandbox", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Clé publique Waffo Base64 (X.509 DER) de sandbox",
......
...@@ -14,7 +14,9 @@ ...@@ -14,7 +14,9 @@
",点击更新": "、クリックして更新してください", ",点击更新": "、クリックして更新してください",
"(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "", "(共 {{total}} 个)": "",
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(現在、Epay APIのみに対応しています。デフォルトで、上記のサーバーURLがコールバックアドレスとして使用されます。)", "当前仅支持易支付接口,回调地址请在通用设置中配置。": "現在は Epay API のみ対応しています。コールバックアドレスは一般設定で設定してください。",
"请确认商户和所选环境密钥一致。": "加盟店情報と選択中の環境の鍵が一致していることを確認してください。",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "Merchant、Store、Product と選択中の環境の鍵が一致していることを確認してください。",
"(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)",
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(入力 {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(入力 {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
"(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(入力 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + オーディオ入力 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(入力 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + オーディオ入力 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}",
...@@ -729,6 +731,8 @@ ...@@ -729,6 +731,8 @@
"最低": "最低", "最低": "最低",
"最低充值数量": "", "最低充值数量": "",
"最低充值美元数量": "最低チャージUSD額", "最低充值美元数量": "最低チャージUSD額",
"最低充值美元数量必须大于 0": "最低チャージUSD額は 0 より大きい必要があります",
"留空则自动使用当前站点的默认回调地址": "空欄の場合は現在のサイトのデフォルトのコールバックアドレスを使用します",
"最后使用时间": "最終利用日時", "最后使用时间": "最終利用日時",
"最后更新": "Last Updated", "最后更新": "Last Updated",
"最后请求": "最終リクエスト日時", "最后请求": "最終リクエスト日時",
...@@ -1029,7 +1033,7 @@ ...@@ -1029,7 +1033,7 @@
"响应缺少凭据": "レスポンスに資格情報がありません", "响应缺少凭据": "レスポンスに資格情報がありません",
"响应缺少授权链接": "レスポンスに認可リンクがありません", "响应缺少授权链接": "レスポンスに認可リンクがありません",
"商品价格 ID": "料金ID", "商品价格 ID": "料金ID",
"商户 ID": "", "商户 ID": "加盟店 ID",
"回答内容": "回答", "回答内容": "回答",
"回调 URL 填": "コールバックURLを入力してください", "回调 URL 填": "コールバックURLを入力してください",
"回调 URL 格式": "コールバックURL形式", "回调 URL 格式": "コールバックURL形式",
...@@ -1683,7 +1687,7 @@ ...@@ -1683,7 +1687,7 @@
"支付渠道": "決済チャネル", "支付渠道": "決済チャネル",
"支付设置": "決済", "支付设置": "決済",
"支付请求失败": "決済リクエストに失敗しました", "支付请求失败": "決済リクエストに失敗しました",
"支付返回地址": "", "支付返回地址": "返却先 URL",
"支付金额": "決済金額", "支付金额": "決済金額",
"支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能", "支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "6桁のTOTP認証コードまたは8桁のバックアップコードに対応しています。`アカウント設定 - セキュリティ設定 - 2要素認証設定`で設定または確認できます。", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "6桁のTOTP認証コードまたは8桁のバックアップコードに対応しています。`アカウント設定 - セキュリティ設定 - 2要素認証設定`で設定または確認できます。",
...@@ -2174,7 +2178,7 @@ ...@@ -2174,7 +2178,7 @@
"永久删除您的两步验证设置": "2要素認証設定を永久に削除", "永久删除您的两步验证设置": "2要素認証設定を永久に削除",
"永久删除所有备用码(包括未使用的)": "すべてのバックアップコード(未使用分を含む)を永久に削除", "永久删除所有备用码(包括未使用的)": "すべてのバックアップコード(未使用分を含む)を永久に削除",
"汇率": "為替レート", "汇率": "為替レート",
"沙盒模式": "", "沙盒模式": "サンドボックスモード",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "サンドボックス RSA 秘密鍵 Base64 (PKCS#8 DER)", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "サンドボックス RSA 秘密鍵 Base64 (PKCS#8 DER)",
"沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "サンドボックス Waffo 公開鍵 Base64 (X.509 DER)", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "サンドボックス Waffo 公開鍵 Base64 (X.509 DER)",
......
...@@ -18,7 +18,9 @@ ...@@ -18,7 +18,9 @@
",点击更新": ", нажмите для обновления", ",点击更新": ", нажмите для обновления",
"(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "", "(共 {{total}} 个)": "",
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(В настоящее время поддерживается только интерфейс YiPay, по умолчанию используется адрес сервера выше в качестве адреса обратного вызова!)", "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Сейчас поддерживается только интерфейс Epay. Настройте адрес обратного вызова в общих настройках.",
"请确认商户和所选环境密钥一致。": "Убедитесь, что мерчант и ключи выбранной среды совпадают.",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "Убедитесь, что Merchant, Store, Product и ключи выбранной среды совпадают.",
"(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)", "(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)",
"(筛选后显示 {{count}} 条)_few": "(Показано {{count}} элемента после фильтрации)", "(筛选后显示 {{count}} 条)_few": "(Показано {{count}} элемента после фильтрации)",
"(筛选后显示 {{count}} 条)_many": "(Показано {{count}} элементов после фильтрации)", "(筛选后显示 {{count}} 条)_many": "(Показано {{count}} элементов после фильтрации)",
...@@ -750,6 +752,8 @@ ...@@ -750,6 +752,8 @@
"最低": "Минимум", "最低": "Минимум",
"最低充值数量": "", "最低充值数量": "",
"最低充值美元数量": "Минимальная сумма пополнения в долларах", "最低充值美元数量": "Минимальная сумма пополнения в долларах",
"最低充值美元数量必须大于 0": "Минимальная сумма пополнения в долларах должна быть больше 0",
"留空则自动使用当前站点的默认回调地址": "Оставьте пустым, чтобы использовать адрес обратного вызова сайта по умолчанию",
"最后使用时间": "Время последнего использования", "最后使用时间": "Время последнего использования",
"最后更新": "Last Updated", "最后更新": "Last Updated",
"最后请求": "Последний запрос", "最后请求": "Последний запрос",
...@@ -1050,7 +1054,7 @@ ...@@ -1050,7 +1054,7 @@
"响应缺少凭据": "В ответе отсутствуют учётные данные", "响应缺少凭据": "В ответе отсутствуют учётные данные",
"响应缺少授权链接": "В ответе отсутствует ссылка авторизации", "响应缺少授权链接": "В ответе отсутствует ссылка авторизации",
"商品价格 ID": "ID цены товара", "商品价格 ID": "ID цены товара",
"商户 ID": "", "商户 ID": "ID мерчанта",
"回答内容": "Содержание ответа", "回答内容": "Содержание ответа",
"回调 URL 填": "URL обратного вызова", "回调 URL 填": "URL обратного вызова",
"回调 URL 格式": "Формат URL обратного вызова", "回调 URL 格式": "Формат URL обратного вызова",
...@@ -1730,7 +1734,7 @@ ...@@ -1730,7 +1734,7 @@
"支付渠道": "Платежные каналы", "支付渠道": "Платежные каналы",
"支付设置": "Оплата", "支付设置": "Оплата",
"支付请求失败": "Запрос на оплату не удался", "支付请求失败": "Запрос на оплату не удался",
"支付返回地址": "", "支付返回地址": "Адрес возврата",
"支付金额": "Сумма оплаты", "支付金额": "Сумма оплаты",
"支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения", "支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Поддерживает 6-значные TOTP коды подтверждения или 8-значные резервные коды, можно настроить или просмотреть в `Личные настройки-Настройки безопасности-Настройки двухфакторной аутентификации`.", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Поддерживает 6-значные TOTP коды подтверждения или 8-значные резервные коды, можно настроить или просмотреть в `Личные настройки-Настройки безопасности-Настройки двухфакторной аутентификации`.",
...@@ -2221,7 +2225,7 @@ ...@@ -2221,7 +2225,7 @@
"永久删除您的两步验证设置": "Окончательно удалить настройки двухфакторной аутентификации", "永久删除您的两步验证设置": "Окончательно удалить настройки двухфакторной аутентификации",
"永久删除所有备用码(包括未使用的)": "Окончательно удалить все резервные коды (включая неиспользованные)", "永久删除所有备用码(包括未使用的)": "Окончательно удалить все резервные коды (включая неиспользованные)",
"汇率": "Обменный курс", "汇率": "Обменный курс",
"沙盒模式": "", "沙盒模式": "Режим песочницы",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "RSA закрытый ключ Base64 (PKCS#8 DER) песочницы", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "RSA закрытый ключ Base64 (PKCS#8 DER) песочницы",
"沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Открытый ключ Waffo Base64 (X.509 DER) песочницы", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Открытый ключ Waffo Base64 (X.509 DER) песочницы",
......
...@@ -14,7 +14,9 @@ ...@@ -14,7 +14,9 @@
",点击更新": ", nhấn để cập nhật", ",点击更新": ", nhấn để cập nhật",
"(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "", "(共 {{total}} 个)": "",
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Hiện tại chỉ hỗ trợ giao diện Epay, địa chỉ máy chủ phía trên được sử dụng làm địa chỉ gọi lại theo mặc định!)", "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Hiện tại chỉ hỗ trợ giao diện Epay. Hãy cấu hình địa chỉ gọi lại trong cài đặt chung.",
"请确认商户和所选环境密钥一致。": "Hãy đảm bảo merchant và khóa của môi trường đã chọn khớp nhau.",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "Hãy đảm bảo Merchant, Store, Product và khóa của môi trường đã chọn khớp nhau.",
"(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)",
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(Đầu vào {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(Đầu vào {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
"(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(Đầu vào {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + Đầu vào âm thanh {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(Đầu vào {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + Đầu vào âm thanh {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}",
...@@ -730,6 +732,8 @@ ...@@ -730,6 +732,8 @@
"最低": "thấp nhất", "最低": "thấp nhất",
"最低充值数量": "", "最低充值数量": "",
"最低充值美元数量": "Số tiền nạp đô la tối thiểu", "最低充值美元数量": "Số tiền nạp đô la tối thiểu",
"最低充值美元数量必须大于 0": "Số tiền nạp đô la tối thiểu phải lớn hơn 0",
"留空则自动使用当前站点的默认回调地址": "Để trống để dùng địa chỉ gọi lại mặc định của trang hiện tại",
"最后使用时间": "Thời gian sử dụng cuối cùng", "最后使用时间": "Thời gian sử dụng cuối cùng",
"最后更新": "Last Updated", "最后更新": "Last Updated",
"最后请求": "Yêu cầu cuối cùng", "最后请求": "Yêu cầu cuối cùng",
...@@ -1030,7 +1034,7 @@ ...@@ -1030,7 +1034,7 @@
"响应缺少凭据": "Phản hồi thiếu thông tin xác thực", "响应缺少凭据": "Phản hồi thiếu thông tin xác thực",
"响应缺少授权链接": "Phản hồi thiếu liên kết xác thực", "响应缺少授权链接": "Phản hồi thiếu liên kết xác thực",
"商品价格 ID": "ID giá sản phẩm", "商品价格 ID": "ID giá sản phẩm",
"商户 ID": "", "商户 ID": "ID người bán",
"回答内容": "Nội dung trả lời", "回答内容": "Nội dung trả lời",
"回调 URL 填": "Điền URL gọi lại", "回调 URL 填": "Điền URL gọi lại",
"回调 URL 格式": "Định dạng URL callback", "回调 URL 格式": "Định dạng URL callback",
...@@ -1684,7 +1688,7 @@ ...@@ -1684,7 +1688,7 @@
"支付渠道": "Kênh thanh toán", "支付渠道": "Kênh thanh toán",
"支付设置": "Thanh toán", "支付设置": "Thanh toán",
"支付请求失败": "Yêu cầu thanh toán thất bại", "支付请求失败": "Yêu cầu thanh toán thất bại",
"支付返回地址": "", "支付返回地址": "URL trả về",
"支付金额": "Số tiền thanh toán", "支付金额": "Số tiền thanh toán",
"支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh", "支持 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ố`.", "支持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ố`.",
...@@ -2211,7 +2215,7 @@ ...@@ -2211,7 +2215,7 @@
"永久删除所有备用码(包括未使用的)": "Xóa vĩnh viễn tất cả các mã dự phòng (bao gồm cả mã chưa sử dụng)", "永久删除所有备用码(包括未使用的)": "Xóa vĩnh viễn tất cả các mã dự phòng (bao gồm cả mã chưa sử dụng)",
"永久有效": "Có hiệu lực vĩnh viễn", "永久有效": "Có hiệu lực vĩnh viễn",
"汇率": "Tỷ giá hối đoái", "汇率": "Tỷ giá hối đoái",
"沙盒模式": "", "沙盒模式": "Chế độ Sandbox",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Khóa riêng RSA Base64 (PKCS#8 DER) môi trường sandbox", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Khóa riêng RSA Base64 (PKCS#8 DER) môi trường sandbox",
"沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Khóa công khai Waffo Base64 (X.509 DER) môi trường sandbox", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Khóa công khai Waffo Base64 (X.509 DER) môi trường sandbox",
......
...@@ -12,7 +12,9 @@ ...@@ -12,7 +12,9 @@
",点击更新": ",点击更新", ",点击更新": ",点击更新",
"(共 {{total}} 个,省略 {{omit}} 个)": "(共 {{total}} 个,省略 {{omit}} 个)", "(共 {{total}} 个,省略 {{omit}} 个)": "(共 {{total}} 个,省略 {{omit}} 个)",
"(共 {{total}} 个)": "(共 {{total}} 个)", "(共 {{total}} 个)": "(共 {{total}} 个)",
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)", "当前仅支持易支付接口,回调地址请在通用设置中配置。": "当前仅支持易支付接口,回调地址请在通用设置中配置。",
"请确认商户和所选环境密钥一致。": "请确认商户和所选环境密钥一致。",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "请确认 Merchant、Store、Product 和所选环境密钥一致。",
"(筛选后显示 {{count}} 条)_other": "(筛选后显示 {{count}} 条)", "(筛选后显示 {{count}} 条)_other": "(筛选后显示 {{count}} 条)",
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
"(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}",
...@@ -718,6 +720,8 @@ ...@@ -718,6 +720,8 @@
"最低": "最低", "最低": "最低",
"最低充值数量": "最低充值数量", "最低充值数量": "最低充值数量",
"最低充值美元数量": "最低充值美元数量", "最低充值美元数量": "最低充值美元数量",
"最低充值美元数量必须大于 0": "最低充值美元数量必须大于 0",
"留空则自动使用当前站点的默认回调地址": "留空则自动使用当前站点的默认回调地址",
"最后使用时间": "最后使用时间", "最后使用时间": "最后使用时间",
"最后更新": "最后更新", "最后更新": "最后更新",
"最后请求": "最后请求", "最后请求": "最后请求",
...@@ -1671,6 +1675,7 @@ ...@@ -1671,6 +1675,7 @@
"支付方式类型": "支付方式类型", "支付方式类型": "支付方式类型",
"支付渠道": "支付渠道", "支付渠道": "支付渠道",
"支付设置": "支付设置", "支付设置": "支付设置",
"易支付设置": "易支付设置",
"支付请求失败": "支付请求失败", "支付请求失败": "支付请求失败",
"支付返回地址": "支付返回地址", "支付返回地址": "支付返回地址",
"支付金额": "支付金额", "支付金额": "支付金额",
...@@ -1895,6 +1900,7 @@ ...@@ -1895,6 +1900,7 @@
"更新成功": "更新成功", "更新成功": "更新成功",
"更新所有已启用通道余额": "更新所有已启用通道余额", "更新所有已启用通道余额": "更新所有已启用通道余额",
"更新支付设置": "更新支付设置", "更新支付设置": "更新支付设置",
"更新易支付设置": "更新易支付设置",
"更新时间": "更新时间", "更新时间": "更新时间",
"更新服务器地址": "更新服务器地址", "更新服务器地址": "更新服务器地址",
"更新模型信息": "更新模型信息", "更新模型信息": "更新模型信息",
...@@ -3194,6 +3200,44 @@ ...@@ -3194,6 +3200,44 @@
"豆包": "豆包", "豆包": "豆包",
"账单": "账单", "账单": "账单",
"账户充值": "账户充值", "账户充值": "账户充值",
"Waffo Pancake 设置": "Waffo Pancake 设置",
"Waffo 设置": "Waffo 设置",
"Waffo Pancake": "Waffo Pancake",
"启用 Waffo Pancake": "启用 Waffo Pancake",
"当前入口状态": "当前入口状态",
"生产环境": "生产环境",
"测试环境": "测试环境",
"支付方式名称": "支付方式名称",
"支付方式颜色": "支付方式颜色",
"支付方式图标": "支付方式图标",
"可选,填写图片 URL": "可选,填写图片 URL",
"商户 ID": "商户 ID",
"Store ID": "Store ID",
"Product ID": "Product ID",
"API 私钥": "API 私钥",
"Webhook 公钥": "Webhook 公钥",
"充值价格必须大于 0": "充值价格必须大于 0",
"最低充值数量必须大于 0": "最低充值数量必须大于 0",
"充值完成后跳回的页面": "充值完成后跳回的页面",
"启用后会按测试环境保存这组配置": "启用后会按测试环境保存这组配置",
"更新 Waffo Pancake 设置": "更新 Waffo Pancake 设置",
"一次性余额充值": "一次性余额充值",
"新支付方式": "新支付方式",
"付款完成后将自动回到账户页": "付款完成后将自动回到账户页",
"一次性支付,付款后自动返回": "一次性支付,付款后自动返回",
"选择金额后直接跳转到 Waffo Pancake 结账页,支付完成后会回到账户页。": "选择金额后直接跳转到 Waffo Pancake 结账页,支付完成后会回到账户页。",
"当前金额未达到 Waffo Pancake 的最低充值要求": "当前金额未达到 Waffo Pancake 的最低充值要求",
"请先选择不低于最低额度的充值金额": "请先选择不低于最低额度的充值金额",
"该入口仅用于一次性余额充值": "该入口仅用于一次性余额充值",
"立即充值": "立即充值",
"生产 Webhook 公钥": "生产 Webhook 公钥",
"测试 Webhook 公钥": "测试 Webhook 公钥",
"生产环境 Webhook 验签公钥 Base64": "生产环境 Webhook 验签公钥 Base64",
"测试环境 Webhook 验签公钥 Base64": "测试环境 Webhook 验签公钥 Base64",
"请输入支付方式名称": "请输入支付方式名称",
"请输入商户 ID": "请输入商户 ID",
"请输入 Store ID": "请输入 Store ID",
"请输入 Product ID": "请输入 Product ID",
"账户已删除!": "账户已删除!", "账户已删除!": "账户已删除!",
"账户已锁定": "账户已锁定", "账户已锁定": "账户已锁定",
"账户数据": "账户数据", "账户数据": "账户数据",
......
...@@ -12,7 +12,9 @@ ...@@ -12,7 +12,9 @@
",点击更新": ",點擊更新", ",点击更新": ",點擊更新",
"(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "", "(共 {{total}} 个)": "",
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(當前僅支援易支付接口,預設使用上方伺服器位址作為回調位址!)", "当前仅支持易支付接口,回调地址请在通用设置中配置。": "目前僅支援易支付接口,回調位址請在通用設定中配置。",
"请确认商户和所选环境密钥一致。": "請確認商戶與所選環境密鑰一致。",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "請確認 Merchant、Store、Product 與所選環境密鑰一致。",
"(筛选后显示 {{count}} 条)_other": "(篩選後顯示 {{count}} 條)", "(筛选后显示 {{count}} 条)_other": "(篩選後顯示 {{count}} 條)",
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(輸入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(輸入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
"(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(輸入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音訊輸入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(輸入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音訊輸入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}",
...@@ -726,6 +728,8 @@ ...@@ -726,6 +728,8 @@
"最低": "最低", "最低": "最低",
"最低充值数量": "", "最低充值数量": "",
"最低充值美元数量": "最低儲值美元數量", "最低充值美元数量": "最低儲值美元數量",
"最低充值美元数量必须大于 0": "最低儲值美元數量必須大於 0",
"留空则自动使用当前站点的默认回调地址": "留空則自動使用目前站點的預設回調位址",
"最后使用时间": "最後使用時間", "最后使用时间": "最後使用時間",
"最后更新": "最後更新", "最后更新": "最後更新",
"最后请求": "最後請求", "最后请求": "最後請求",
...@@ -1027,7 +1031,7 @@ ...@@ -1027,7 +1031,7 @@
"响应缺少凭据": "", "响应缺少凭据": "",
"响应缺少授权链接": "", "响应缺少授权链接": "",
"商品价格 ID": "商品價格 ID", "商品价格 ID": "商品價格 ID",
"商户 ID": "", "商户 ID": "商戶 ID",
"回答内容": "回答內容", "回答内容": "回答內容",
"回调 URL 填": "回調 URL 填", "回调 URL 填": "回調 URL 填",
"回调 URL 格式": "回調 URL 格式", "回调 URL 格式": "回調 URL 格式",
...@@ -1683,7 +1687,7 @@ ...@@ -1683,7 +1687,7 @@
"支付渠道": "支付管道", "支付渠道": "支付管道",
"支付设置": "支付設定", "支付设置": "支付設定",
"支付请求失败": "支付請求失敗", "支付请求失败": "支付請求失敗",
"支付返回地址": "", "支付返回地址": "支付返回位址",
"支付金额": "支付金額", "支付金额": "支付金額",
"支持 Ctrl+V 粘贴图片": "支援 Ctrl+V 貼上圖片", "支持 Ctrl+V 粘贴图片": "支援 Ctrl+V 貼上圖片",
"支持 JSONPath,如 email, data.user.email": "支援 JSONPath,如 email, data.user.email", "支持 JSONPath,如 email, data.user.email": "支援 JSONPath,如 email, data.user.email",
...@@ -2181,7 +2185,7 @@ ...@@ -2181,7 +2185,7 @@
"永久删除您的两步验证设置": "永久刪除您的兩步驗證設定", "永久删除您的两步验证设置": "永久刪除您的兩步驗證設定",
"永久删除所有备用码(包括未使用的)": "永久刪除所有備用碼(包括未使用的)", "永久删除所有备用码(包括未使用的)": "永久刪除所有備用碼(包括未使用的)",
"汇率": "匯率", "汇率": "匯率",
"沙盒模式": "", "沙盒模式": "沙盒模式",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "沙盒環境 RSA 私鑰 Base64 (PKCS#8 DER)", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "沙盒環境 RSA 私鑰 Base64 (PKCS#8 DER)",
"沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "沙盒環境 Waffo 公鑰 Base64 (X.509 DER)", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "沙盒環境 Waffo 公鑰 Base64 (X.509 DER)",
......
...@@ -18,29 +18,43 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,29 +18,43 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { Button, Form, Spin } from '@douyinfe/semi-ui'; import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import { import {
API, API,
removeTrailingSlash, removeTrailingSlash,
showError, showError,
showSuccess, showSuccess,
verifyJSON,
} from '../../../helpers'; } from '../../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export default function SettingsGeneralPayment(props) { export default function SettingsGeneralPayment(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('通用设置');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
ServerAddress: '', ServerAddress: '',
CustomCallbackAddress: '',
TopupGroupRatio: '',
PayMethods: '',
AmountOptions: '',
AmountDiscount: '',
}); });
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null); const formApiRef = useRef(null);
useEffect(() => { useEffect(() => {
if (props.options && formApiRef.current) { if (props.options && formApiRef.current) {
const currentInputs = { const currentInputs = {
ServerAddress: props.options.ServerAddress || '', ServerAddress: props.options.ServerAddress || '',
CustomCallbackAddress: props.options.CustomCallbackAddress || '',
TopupGroupRatio: props.options.TopupGroupRatio || '',
PayMethods: props.options.PayMethods || '',
AmountOptions: props.options.AmountOptions || '',
AmountDiscount: props.options.AmountDiscount || '',
}; };
setInputs(currentInputs); setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs); formApiRef.current.setValues(currentInputs);
} }
}, [props.options]); }, [props.options]);
...@@ -49,19 +63,93 @@ export default function SettingsGeneralPayment(props) { ...@@ -49,19 +63,93 @@ export default function SettingsGeneralPayment(props) {
setInputs(values); setInputs(values);
}; };
const submitServerAddress = async () => { const submitGeneralSettings = async () => {
if (
originInputs.TopupGroupRatio !== inputs.TopupGroupRatio &&
!verifyJSON(inputs.TopupGroupRatio)
) {
showError(t('充值分组倍率不是合法的 JSON 字符串'));
return;
}
if (
originInputs.PayMethods !== inputs.PayMethods &&
!verifyJSON(inputs.PayMethods)
) {
showError(t('充值方式设置不是合法的 JSON 字符串'));
return;
}
if (
originInputs.AmountOptions !== inputs.AmountOptions &&
inputs.AmountOptions.trim() !== '' &&
!verifyJSON(inputs.AmountOptions)
) {
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
return;
}
if (
originInputs.AmountDiscount !== inputs.AmountDiscount &&
inputs.AmountDiscount.trim() !== '' &&
!verifyJSON(inputs.AmountDiscount)
) {
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
return;
}
setLoading(true); setLoading(true);
try { try {
let ServerAddress = removeTrailingSlash(inputs.ServerAddress); const options = [
const res = await API.put('/api/option/', { {
key: 'ServerAddress', key: 'ServerAddress',
value: ServerAddress, value: removeTrailingSlash(inputs.ServerAddress),
},
];
if (inputs.CustomCallbackAddress !== '') {
options.push({
key: 'CustomCallbackAddress',
value: removeTrailingSlash(inputs.CustomCallbackAddress),
}); });
if (res.data.success) { }
if (originInputs.TopupGroupRatio !== inputs.TopupGroupRatio) {
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
}
if (originInputs.PayMethods !== inputs.PayMethods) {
options.push({ key: 'PayMethods', value: inputs.PayMethods });
}
if (originInputs.AmountOptions !== inputs.AmountOptions) {
options.push({
key: 'payment_setting.amount_options',
value: inputs.AmountOptions,
});
}
if (originInputs.AmountDiscount !== inputs.AmountDiscount) {
options.push({
key: 'payment_setting.amount_discount',
value: inputs.AmountDiscount,
});
}
const results = await Promise.all(
options.map((option) =>
API.put('/api/option/', {
key: option.key,
value: option.value,
}),
),
);
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length === 0) {
showSuccess(t('更新成功')); showSuccess(t('更新成功'));
setOriginInputs({ ...inputs });
props.refresh && props.refresh(); props.refresh && props.refresh();
} else { } else {
errorResults.forEach((res) => {
showError(res.data.message); showError(res.data.message);
});
} }
} catch (error) { } catch (error) {
showError(t('更新失败')); showError(t('更新失败'));
...@@ -76,7 +164,7 @@ export default function SettingsGeneralPayment(props) { ...@@ -76,7 +164,7 @@ export default function SettingsGeneralPayment(props) {
onValueChange={handleFormChange} onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)} getFormApi={(api) => (formApiRef.current = api)}
> >
<Form.Section text={t('通用设置')}> <Form.Section text={sectionTitle}>
<Form.Input <Form.Input
field='ServerAddress' field='ServerAddress'
label={t('服务器地址')} label={t('服务器地址')}
...@@ -86,7 +174,73 @@ export default function SettingsGeneralPayment(props) { ...@@ -86,7 +174,73 @@ export default function SettingsGeneralPayment(props) {
'该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置', '该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置',
)} )}
/> />
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button> <Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='CustomCallbackAddress'
label={t('回调地址')}
placeholder={t('例如:https://yourdomain.com')}
extraText={t(
'留空时默认使用服务器地址作为回调地址,填写后将覆盖默认值',
)}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='TopupGroupRatio'
label={t('充值分组倍率')}
placeholder={t('为一个 JSON 文本,键为组名称,值为倍率')}
autosize
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='PayMethods'
label={t('充值方式设置')}
placeholder={t('为一个 JSON 文本')}
autosize
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='AmountOptions'
label={t('自定义充值数量选项')}
placeholder={t(
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
)}
autosize
extraText={t(
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
)}
/>
</Col>
</Row>
<Row style={{ marginTop: 16 }}>
<Col span={24}>
<Form.TextArea
field='AmountDiscount'
label={t('充值金额折扣配置')}
placeholder={t(
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
)}
autosize
extraText={t(
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
)}
/>
</Col>
</Row>
<Button onClick={submitGeneralSettings} style={{ marginTop: 16 }}>
{t('保存通用设置')}
</Button>
</Form.Section> </Form.Section>
</Form> </Form>
</Spin> </Spin>
......
...@@ -34,10 +34,11 @@ import { ...@@ -34,10 +34,11 @@ import {
const { Text } = Typography; const { Text } = Typography;
import { API, showError, showSuccess } from '../../../helpers'; import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Plus, Trash2 } from 'lucide-react'; import { BookOpen, Plus, Trash2 } from 'lucide-react';
export default function SettingsPaymentGatewayCreem(props) { export default function SettingsPaymentGatewayCreem(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('Creem 设置');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
CreemApiKey: '', CreemApiKey: '',
...@@ -259,15 +260,22 @@ export default function SettingsPaymentGatewayCreem(props) { ...@@ -259,15 +260,22 @@ export default function SettingsPaymentGatewayCreem(props) {
onValueChange={handleFormChange} onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)} getFormApi={(api) => (formApiRef.current = api)}
> >
<Form.Section text={t('Creem 设置')}> <Form.Section text={sectionTitle}>
<Text> <Banner
type='info'
icon={<BookOpen size={16} />}
description={
<>
{t('Creem 介绍')} {t('Creem 介绍')}
<a href='https://creem.io' target='_blank' rel='noreferrer'> <a href='https://creem.io' target='_blank' rel='noreferrer'>
Creem Official Site Creem Official Site
</a> </a>
<br /> <br />
</Text> {t('Creem Setting Tips')}
<Banner type='info' description={t('Creem Setting Tips')} /> </>
}
style={{ marginBottom: 16 }}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}> <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
...@@ -281,7 +289,7 @@ export default function SettingsPaymentGatewayCreem(props) { ...@@ -281,7 +289,7 @@ export default function SettingsPaymentGatewayCreem(props) {
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input <Form.Input
field='CreemWebhookSecret' field='CreemWebhookSecret'
label={t('Webhook 密钥')} label={t('Webhook 签名密钥')}
placeholder={t( placeholder={t(
'用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示', '用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示',
)} )}
...@@ -291,7 +299,7 @@ export default function SettingsPaymentGatewayCreem(props) { ...@@ -291,7 +299,7 @@ export default function SettingsPaymentGatewayCreem(props) {
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch <Form.Switch
field='CreemTestMode' field='CreemTestMode'
label={t('测试模式')} label={t('沙盒模式')}
extraText={t('启用后将使用 Creem Test Mode')} extraText={t('启用后将使用 Creem Test Mode')}
/> />
</Col> </Col>
......
...@@ -18,16 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,16 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { import { Banner, Button, Form, Row, Col, Spin } from '@douyinfe/semi-ui';
Banner,
Button,
Form,
Row,
Col,
Typography,
Spin,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import { import {
API, API,
removeTrailingSlash, removeTrailingSlash,
...@@ -35,9 +26,11 @@ import { ...@@ -35,9 +26,11 @@ import {
showSuccess, showSuccess,
} from '../../../helpers'; } from '../../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BookOpen, TriangleAlert } from 'lucide-react';
export default function SettingsPaymentGateway(props) { export default function SettingsPaymentGateway(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('Stripe 设置');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
StripeApiSecret: '', StripeApiSecret: '',
...@@ -165,8 +158,12 @@ export default function SettingsPaymentGateway(props) { ...@@ -165,8 +158,12 @@ export default function SettingsPaymentGateway(props) {
onValueChange={handleFormChange} onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)} getFormApi={(api) => (formApiRef.current = api)}
> >
<Form.Section text={t('Stripe 设置')}> <Form.Section text={sectionTitle}>
<Text> <Banner
type='info'
icon={<BookOpen size={16} />}
description={
<>
Stripe 密钥、Webhook 等设置请 Stripe 密钥、Webhook 等设置请
<a <a
href='https://dashboard.stripe.com/developers' href='https://dashboard.stripe.com/developers'
...@@ -175,7 +172,7 @@ export default function SettingsPaymentGateway(props) { ...@@ -175,7 +172,7 @@ export default function SettingsPaymentGateway(props) {
> >
点击此处 点击此处
</a> </a>
进行设置,最好先在 进行设置,建议先在
<a <a
href='https://dashboard.stripe.com/test/developers' href='https://dashboard.stripe.com/test/developers'
target='_blank' target='_blank'
...@@ -183,24 +180,31 @@ export default function SettingsPaymentGateway(props) { ...@@ -183,24 +180,31 @@ export default function SettingsPaymentGateway(props) {
> >
测试环境 测试环境
</a> </a>
进行测试 完成联调
<br /> <br />
</Text> {t('回调地址')}
<Banner {props.options.ServerAddress
type='info' ? removeTrailingSlash(props.options.ServerAddress)
description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/stripe/webhook`} : t('网站地址')}
/api/stripe/webhook
</>
}
style={{ marginBottom: 12 }}
/> />
<Banner <Banner
type='warning' type='warning'
description={`需要包含事件:checkout.session.completed 和 checkout.session.expired`} icon={<TriangleAlert size={16} />}
description='需要包含事件:checkout.session.completed 和 checkout.session.expired'
style={{ marginBottom: 16 }}
/> />
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}> <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input <Form.Input
field='StripeApiSecret' field='StripeApiSecret'
label={t('API 密钥')} label={t('API 密钥')}
placeholder={t( placeholder={t('例如:sk_xxx 或 rk_xxx,留空表示保持当前不变')}
'sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示', extraText={t(
'保存后不会回显,请填写当前环境对应的 Stripe API 密钥',
)} )}
type='password' type='password'
/> />
...@@ -209,7 +213,8 @@ export default function SettingsPaymentGateway(props) { ...@@ -209,7 +213,8 @@ export default function SettingsPaymentGateway(props) {
<Form.Input <Form.Input
field='StripeWebhookSecret' field='StripeWebhookSecret'
label={t('Webhook 签名密钥')} label={t('Webhook 签名密钥')}
placeholder={t('whsec_xxx 的 Webhook 签名密钥,敏感信息不显示')} placeholder={t('例如:whsec_xxx,留空表示保持当前不变')}
extraText={t('用于校验 Stripe Webhook 签名,保存后不会回显')}
type='password' type='password'
/> />
</Col> </Col>
...@@ -217,7 +222,8 @@ export default function SettingsPaymentGateway(props) { ...@@ -217,7 +222,8 @@ export default function SettingsPaymentGateway(props) {
<Form.Input <Form.Input
field='StripePriceId' field='StripePriceId'
label={t('商品价格 ID')} label={t('商品价格 ID')}
placeholder={t('price_xxx 的商品价格 ID,新建产品后可获得')} placeholder={t('例如:price_xxx')}
extraText={t('在 Stripe 后台创建价格后获得')}
/> />
</Col> </Col>
</Row> </Row>
...@@ -231,6 +237,7 @@ export default function SettingsPaymentGateway(props) { ...@@ -231,6 +237,7 @@ export default function SettingsPaymentGateway(props) {
precision={2} precision={2}
label={t('充值价格(x元/美金)')} label={t('充值价格(x元/美金)')}
placeholder={t('例如:7,就是7元/美金')} placeholder={t('例如:7,就是7元/美金')}
extraText={t('按 1 美元对应的站内价格填写')}
/> />
</Col> </Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
...@@ -238,6 +245,7 @@ export default function SettingsPaymentGateway(props) { ...@@ -238,6 +245,7 @@ export default function SettingsPaymentGateway(props) {
field='StripeMinTopUp' field='StripeMinTopUp'
label={t('最低充值美元数量')} label={t('最低充值美元数量')}
placeholder={t('例如:2,就是最低充值2$')} placeholder={t('例如:2,就是最低充值2$')}
extraText={t('用户单次最少可充值的美元数量')}
/> />
</Col> </Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}> <Col xs={24} sm={24} md={8} lg={8} xl={8}>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment