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

rafactor: payment
This commit is contained in:
Seefs
2026-04-18 14:22:54 +08:00
committed by GitHub
parent 5b9dcf1bda
commit f995a868e4
41 changed files with 3222 additions and 740 deletions
+12 -2
View File
@@ -27,6 +27,15 @@ var completionRatioMetaOptionKeys = []string{
"AudioCompletionRatio",
}
func isVisiblePublicKeyOption(key string) bool {
switch key {
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
return true
default:
return false
}
}
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" {
return
@@ -66,11 +75,12 @@ func GetOptions(c *gin.Context) {
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
value := common.Interface2String(v)
if strings.HasSuffix(k, "Token") ||
isSensitiveKey := strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key") {
strings.HasSuffix(k, "api_key")
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
continue
}
options = append(options, &model.Option{
+100
View File
@@ -0,0 +1,100 @@
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()
}
@@ -0,0 +1,166 @@
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())
}
+12 -10
View File
@@ -2,11 +2,13 @@ package controller
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -24,14 +26,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
// Keep body for debugging consistency (like RequestCreemPay)
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("read subscription creem pay req body err: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付请求读取失败 error=%q", err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
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
}
@@ -85,12 +87,12 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: referenceId,
PaymentMethod: PaymentMethodCreem,
PaymentMethod: model.PaymentMethodCreem,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := order.Insert(); err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
@@ -112,14 +114,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
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 {
log.Printf("获取Creem支付链接失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": checkoutUrl,
+3 -3
View File
@@ -104,7 +104,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
ReturnUrl: returnUrl,
})
if err != nil {
_ = model.ExpireSubscriptionOrder(tradeNo)
_ = model.ExpireSubscriptionOrder(tradeNo, req.PaymentMethod)
common.ApiErrorMsg(c, "拉起支付失败")
return
}
@@ -156,7 +156,7 @@ func SubscriptionEpayNotify(c *gin.Context) {
LockOrder(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"))
return
}
@@ -205,7 +205,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
LockOrder(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")
return
}
+3 -3
View File
@@ -2,12 +2,12 @@ package controller
import (
"fmt"
"log"
"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/setting"
"github.com/QuantumNous/new-api/setting/system_setting"
@@ -78,7 +78,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
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": "拉起支付失败"})
return
}
@@ -88,7 +88,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
PlanId: plan.Id,
Money: plan.PriceAmount,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
PaymentMethod: model.PaymentMethodStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
+99 -57
View File
@@ -2,7 +2,7 @@ package controller
import (
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"sync"
@@ -27,7 +27,7 @@ func GetTopUpInfo(c *gin.Context) {
payMethods := operation_setting.PayMethods
// 如果启用了 Stripe 支付,添加到支付方法列表
if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
if isStripeTopUpEnabled() {
// 检查是否已经包含 Stripe
hasStripe := false
for _, method := range payMethods {
@@ -49,19 +49,11 @@ func GetTopUpInfo(c *gin.Context) {
}
// 如果启用了 Waffo 支付,添加到支付方法列表
enableWaffo := setting.WaffoEnabled &&
((!setting.WaffoSandbox &&
setting.WaffoApiKey != "" &&
setting.WaffoPrivateKey != "" &&
setting.WaffoPublicCert != "") ||
(setting.WaffoSandbox &&
setting.WaffoSandboxApiKey != "" &&
setting.WaffoSandboxPrivateKey != "" &&
setting.WaffoSandboxPublicCert != ""))
enableWaffo := isWaffoTopUpEnabled()
if enableWaffo {
hasWaffo := false
for _, method := range payMethods {
if method["type"] == "waffo" {
if method["type"] == model.PaymentMethodWaffo {
hasWaffo = true
break
}
@@ -70,7 +62,7 @@ func GetTopUpInfo(c *gin.Context) {
if !hasWaffo {
waffoMethod := map[string]string{
"name": "Waffo (Global Payment)",
"type": "waffo",
"type": model.PaymentMethodWaffo,
"color": "rgba(var(--semi-blue-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
}
@@ -78,24 +70,46 @@ func GetTopUpInfo(c *gin.Context) {
}
}
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
if enableWaffoPancake {
hasWaffoPancake := false
for _, method := range payMethods {
if method["type"] == model.PaymentMethodWaffoPancake {
hasWaffoPancake = true
break
}
}
if !hasWaffoPancake {
payMethods = append(payMethods, map[string]string{
"name": "Waffo Pancake",
"type": model.PaymentMethodWaffoPancake,
"color": "rgba(var(--semi-orange-5), 1)",
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
})
}
}
data := gin.H{
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
"enable_waffo_topup": enableWaffo,
"enable_online_topup": isEpayTopUpEnabled(),
"enable_stripe_topup": isStripeTopUpEnabled(),
"enable_creem_topup": isCreemTopUpEnabled(),
"enable_waffo_topup": enableWaffo,
"enable_waffo_pancake_topup": enableWaffoPancake,
"waffo_pay_methods": func() interface{} {
if enableWaffo {
return setting.GetWaffoPayMethods()
}
return nil
}(),
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"waffo_min_topup": setting.WaffoMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"waffo_min_topup": setting.WaffoMinTopUp,
"waffo_pancake_min_topup": setting.WaffoPancakeMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
}
common.ApiSuccess(c, data)
}
@@ -109,6 +123,17 @@ type AmountRequest struct {
Amount int64 `json:"amount"`
}
var nonEpayPaymentMethodsForCallback = []string{
model.PaymentMethodStripe,
model.PaymentMethodCreem,
model.PaymentMethodWaffo,
model.PaymentMethodWaffoPancake,
}
func isNonEpayPaymentMethodForEpayCallback(paymentMethod string) bool {
return lo.Contains(nonEpayPaymentMethodsForCallback, paymentMethod)
}
func GetEpayClient() *epay.Client {
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
return nil
@@ -167,28 +192,28 @@ func RequestEpay(c *gin.Context) {
var req EpayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < getMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney < 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
@@ -199,7 +224,7 @@ func RequestEpay(c *gin.Context) {
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
client := GetEpayClient()
if client == nil {
c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
@@ -212,7 +237,8 @@ func RequestEpay(c *gin.Context) {
ReturnUrl: returnUrl,
})
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
amount := req.Amount
@@ -228,14 +254,16 @@ func RequestEpay(c *gin.Context) {
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
CreateTime: time.Now().Unix(),
Status: "pending",
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(200, gin.H{"message": "success", "data": params, "url": uri})
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
}
// tradeNo lock
@@ -281,12 +309,18 @@ func UnlockOrder(tradeNo string) {
}
func EpayNotify(c *gin.Context) {
if !isEpayWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
log.Println("易支付回调POST解析失败:", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
@@ -301,54 +335,63 @@ func EpayNotify(c *gin.Context) {
return r
}, map[string]string{})
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
if len(params) == 0 {
log.Println("易支付回调参数为空")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 参数为空 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, _ = c.Writer.Write([]byte("fail"))
return
}
client := GetEpayClient()
if client == nil {
log.Println("易支付回调失败 未找到配置信息")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
}
return
}
verifyInfo, err := client.Verify(params)
if err == nil && verifyInfo.VerifyStatus {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
_, err := c.Writer.Write([]byte("success"))
if err != nil {
log.Println("易支付回调写入失败")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
}
} else {
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
}
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
} else {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
}
log.Println("易支付回调签名验证失败")
return
}
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
log.Println(verifyInfo)
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo)
if topUp == nil {
log.Printf("易支付回调未找到订单: %v", verifyInfo)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
return
}
if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "creem" || topUp.PaymentMethod == "waffo" {
log.Printf("易支付回调订单支付方式不匹配: %s, 订单号: %s", topUp.PaymentMethod, verifyInfo.ServiceTradeNo)
if isNonEpayPaymentMethodForEpayCallback(topUp.PaymentMethod) {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
return
}
if topUp.Status == "pending" {
topUp.Status = "success"
if topUp.PaymentMethod != verifyInfo.Type {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
return
}
if topUp.Status == common.TopUpStatusPending {
topUp.Status = common.TopUpStatusSuccess
err := topUp.Update()
if err != nil {
log.Printf("易支付回调更新订单失败: %v", topUp)
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
return
}
//user, _ := model.GetUserById(topUp.UserId, false)
@@ -358,14 +401,14 @@ func EpayNotify(c *gin.Context) {
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
if err != nil {
log.Printf("易支付回调更新用户失败: %v", topUp)
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
return
}
log.Printf("易支付回调更新用户成功 %v", topUp)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
}
} else {
log.Printf("易支付异常回调: %v", verifyInfo)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
}
}
@@ -373,26 +416,26 @@ func RequestAmount(c *gin.Context) {
var req AmountRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
if req.Amount < getMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
func GetUserTopUps(c *gin.Context) {
@@ -467,4 +510,3 @@ func AdminCompleteTopUp(c *gin.Context) {
}
common.ApiSuccess(c, nil)
}
+56 -65
View File
@@ -2,6 +2,7 @@ package controller
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -9,10 +10,10 @@ import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"io"
"log"
"net/http"
"time"
@@ -20,10 +21,7 @@ import (
"github.com/thanhpk/randstr"
)
const (
PaymentMethodCreem = "creem"
CreemSignatureHeader = "creem-signature"
)
const CreemSignatureHeader = "creem-signature"
var creemAdaptor = &CreemAdaptor{}
@@ -37,9 +35,9 @@ func generateCreemSignature(payload string, secret string) string {
// 验证Creem webhook签名
func verifyCreemSignature(payload string, signature string, secret string) bool {
if secret == "" {
log.Printf("Creem webhook secret not set")
logger.LogWarn(context.Background(), fmt.Sprintf("Creem webhook secret 未配置 test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
if setting.CreemTestMode {
log.Printf("Skip Creem webhook sign verify in test mode")
logger.LogInfo(context.Background(), fmt.Sprintf("Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q", signature, payload))
return true
}
return false
@@ -66,13 +64,13 @@ type CreemAdaptor struct {
}
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
if req.PaymentMethod != PaymentMethodCreem {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
if req.PaymentMethod != model.PaymentMethodCreem {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
if req.ProductId == "" {
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "请选择产品"})
return
}
@@ -80,8 +78,8 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
var products []CreemProduct
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
if err != nil {
log.Println("解析Creem产品列表失败", err)
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 产品配置解析失败 user_id=%d error=%q", c.GetInt("id"), err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"})
return
}
@@ -95,7 +93,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
}
if selectedProduct == nil {
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
return
}
@@ -112,29 +110,28 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
PaymentMethod: PaymentMethodCreem,
PaymentMethod: model.PaymentMethodCreem,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
log.Printf("创建Creem订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
// 创建支付链接,传入用户邮箱
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, selectedProduct, user.Email, user.Username)
if err != nil {
log.Printf("获取Creem支付链接失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": checkoutUrl,
@@ -149,20 +146,19 @@ func RequestCreemPay(c *gin.Context) {
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("read creem pay req body err: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 支付请求读取失败 error=%q", err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
return
}
// 打印body内容
log.Printf("creem pay request body: %s", string(bodyBytes))
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付请求已收到 user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
err = c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
creemAdaptor.RequestPay(c, &req)
@@ -230,35 +226,37 @@ type CreemWebhookEvent struct {
}
func CreemWebhook(c *gin.Context) {
if !isCreemWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("读取Creem Webhook请求body失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 获取签名头
signature := c.GetHeader(CreemSignatureHeader)
// 打印关键信息(避免输出完整敏感payload)
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
if setting.CreemTestMode {
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
} else if signature == "" {
log.Printf("Creem Webhook缺少签名头")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
if signature == "" {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少签名 path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 验证签名
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
log.Printf("Creem Webhook签名验证失败")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
log.Printf("Creem Webhook签名验证成功")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 验签成功 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
@@ -266,19 +264,19 @@ func CreemWebhook(c *gin.Context) {
// 解析新格式的webhook数据
var webhookEvent CreemWebhookEvent
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
log.Printf("解析Creem Webhook参数失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
c.AbortWithStatus(http.StatusBadRequest)
return
}
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
// 根据事件类型处理不同的webhook
switch webhookEvent.EventType {
case "checkout.completed":
handleCheckoutCompleted(c, &webhookEvent)
default:
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 忽略事件 event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
c.Status(http.StatusOK)
}
}
@@ -287,7 +285,7 @@ func CreemWebhook(c *gin.Context) {
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 验证订单状态
if event.Object.Order.Status != "paid" {
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
c.Status(http.StatusOK)
return
}
@@ -295,7 +293,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 获取引用ID(这是我们创建订单时传递的request_id)
referenceId := event.Object.RequestId
if referenceId == "" {
log.Println("Creem Webhook缺少request_id字段")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少 request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
c.AbortWithStatus(http.StatusBadRequest)
return
}
@@ -303,40 +301,35 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// Try complete subscription order first
LockOrder(referenceId)
defer UnlockOrder(referenceId)
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil {
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event), model.PaymentMethodCreem); err == nil {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
c.Status(http.StatusOK)
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
// 验证订单类型,目前只处理一次性付款(充值)
if event.Object.Order.Type != "onetime" {
log.Printf("暂不支持订单类型: %s, 跳过处理", event.Object.Order.Type)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 暂不支持订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
c.Status(http.StatusOK)
return
}
// 记录详细的支付信息
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
referenceId,
event.Object.Order.Id,
event.Object.Order.AmountPaid,
event.Object.Order.Currency,
event.Object.Product.Name)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
// 查询本地订单确认存在
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Printf("Creem充值订单不存在: %s", referenceId)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 充值订单不存在 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
c.AbortWithStatus(http.StatusBadRequest)
return
}
if topUp.Status != common.TopUpStatusPending {
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
return
}
@@ -347,21 +340,20 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 防护性检查,确保邮箱和姓名不为空字符串
if customerEmail == "" {
log.Printf("警告:Creem回调客户邮箱为空 - 订单号: %s", referenceId)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
}
if customerName == "" {
log.Printf("警告:Creem回调客户姓名为空 - 订单号: %s", referenceId)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
}
err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
if err != nil {
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
referenceId, topUp.Amount, topUp.Money)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
c.Status(http.StatusOK)
}
@@ -379,7 +371,7 @@ type CreemCheckoutResponse struct {
Id string `json:"id"`
}
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct, email string, username string) (string, error) {
if setting.CreemApiKey == "" {
return "", fmt.Errorf("未配置Creem API密钥")
}
@@ -388,7 +380,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
apiUrl := "https://api.creem.io/v1/checkouts"
if setting.CreemTestMode {
apiUrl = "https://test-api.creem.io/v1/checkouts"
log.Printf("使用Creem测试环境: %s", apiUrl)
logger.LogInfo(ctx, fmt.Sprintf("Creem 使用测试环境 api_url=%s", apiUrl))
}
// 构建请求数据,确保包含用户邮箱
@@ -424,8 +416,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", setting.CreemApiKey)
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
apiUrl, product.ProductId, email, referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
// 发送请求
client := &http.Client{
@@ -443,7 +434,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
return "", fmt.Errorf("读取响应失败: %v", err)
}
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
logger.LogInfo(ctx, fmt.Sprintf("Creem API 响应已收到 trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
// 检查响应状态
if resp.StatusCode/100 != 2 {
@@ -460,6 +451,6 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
return "", fmt.Errorf("Creem API resp no checkout url ")
}
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
return checkoutResp.CheckoutUrl, nil
}
+31
View File
@@ -0,0 +1,31 @@
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)
}
})
}
}
+65 -68
View File
@@ -1,16 +1,17 @@
package controller
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"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/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -23,10 +24,6 @@ import (
"github.com/thanhpk/randstr"
)
const (
PaymentMethodStripe = "stripe"
)
var stripeAdaptor = &StripeAdaptor{}
// StripePayRequest represents a payment request for Stripe checkout.
@@ -48,34 +45,34 @@ type StripeAdaptor struct {
func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
if req.Amount < getStripeMinTopup() {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getStripePayMoney(float64(req.Amount), group)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
if req.PaymentMethod != PaymentMethodStripe {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
if req.PaymentMethod != model.PaymentMethodStripe {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
if req.Amount < getStripeMinTopup() {
c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
return
}
if req.Amount > 10000 {
c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10})
c.JSON(http.StatusOK, gin.H{"message": "充值数量不能大于 10000", "data": 10})
return
}
@@ -98,8 +95,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
if err != nil {
log.Println("获取Stripe Checkout支付链接失败", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
@@ -108,16 +105,18 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
PaymentMethod: model.PaymentMethodStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
c.JSON(200, gin.H{
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"pay_link": payLink,
@@ -129,7 +128,7 @@ func RequestStripeAmount(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
stripeAdaptor.RequestAmount(c, &req)
@@ -139,90 +138,93 @@ func RequestStripePay(c *gin.Context) {
var req StripePayRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
stripeAdaptor.RequestPay(c, &req)
}
func StripeWebhook(c *gin.Context) {
if setting.StripeWebhookSecret == "" {
log.Println("Stripe Webhook Secret 未配置,拒绝处理")
ctx := c.Request.Context()
if !isStripeWebhookEnabled() {
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("解析Stripe Webhook参数失败: %v\n", err)
logger.LogError(ctx, fmt.Sprintf("Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusServiceUnavailable)
return
}
signature := c.GetHeader("Stripe-Signature")
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
log.Printf("Stripe Webhook验签失败: %v\n", err)
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
callerIp := c.ClientIP()
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
switch event.Type {
case stripe.EventTypeCheckoutSessionCompleted:
sessionCompleted(event, callerIp)
sessionCompleted(ctx, event, callerIp)
case stripe.EventTypeCheckoutSessionExpired:
sessionExpired(event)
sessionExpired(ctx, event)
case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
sessionAsyncPaymentSucceeded(event, callerIp)
sessionAsyncPaymentSucceeded(ctx, event, callerIp)
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
sessionAsyncPaymentFailed(event, callerIp)
sessionAsyncPaymentFailed(ctx, event, callerIp)
default:
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 忽略事件 event_type=%s client_ip=%s", string(event.Type), callerIp))
}
c.Status(http.StatusOK)
}
func sessionCompleted(event stripe.Event, callerIp string) {
func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string) {
customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "complete" != status {
log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId)
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
return
}
paymentStatus := event.GetObjectValue("payment_status")
if paymentStatus != "paid" {
log.Printf("Stripe Checkout 支付未完成,payment_status: %s, ref: %s(等待异步支付结果)", paymentStatus, referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
return
}
fulfillOrder(event, referenceId, customerId, callerIp)
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
}
// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
// that confirm payment after the checkout session completes.
func sessionAsyncPaymentSucceeded(event stripe.Event, callerIp string) {
func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, callerIp string) {
customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id")
log.Printf("Stripe 异步支付成功: %s", referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付成功 trade_no=%s client_ip=%s", referenceId, callerIp))
fulfillOrder(event, referenceId, customerId, callerIp)
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
}
// sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
// ultimately fail (e.g. bank transfer not received, SEPA rejected).
func sessionAsyncPaymentFailed(event stripe.Event, callerIp string) {
func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp string) {
referenceId := event.GetObjectValue("client_reference_id")
log.Printf("Stripe 异步支付失败: %s", referenceId)
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败 trade_no=%s client_ip=%s", referenceId, callerIp))
if len(referenceId) == 0 {
log.Println("异步支付失败事件未提供支付单号")
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败事件缺少订单号 client_ip=%s", callerIp))
return
}
@@ -231,32 +233,32 @@ func sessionAsyncPaymentFailed(event stripe.Event, callerIp string) {
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Println("异步支付失败,充值订单不存在:", referenceId)
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s", referenceId, callerIp))
return
}
if topUp.PaymentMethod != PaymentMethodStripe {
log.Printf("异步支付失败订单支付方式不匹配: %s, ref: %s", topUp.PaymentMethod, referenceId)
if topUp.PaymentMethod != model.PaymentMethodStripe {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败订单支付方式不匹配 trade_no=%s payment_method=%s client_ip=%s", referenceId, topUp.PaymentMethod, callerIp))
return
}
if topUp.Status != common.TopUpStatusPending {
log.Printf("异步支付失败订单状态非pending: %s, ref: %s", topUp.Status, referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付失败订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
return
}
topUp.Status = common.TopUpStatusFailed
if err := topUp.Update(); err != nil {
log.Printf("标记充值订单失败出错: %v, ref: %s", err, referenceId)
logger.LogError(ctx, fmt.Sprintf("Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
return
}
log.Printf("充值订单已标记为失败: %s", referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s", referenceId, callerIp))
}
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
func fulfillOrder(event stripe.Event, referenceId string, customerId string, callerIp string) {
func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, customerId string, callerIp string) {
if len(referenceId) == 0 {
log.Println("未提供支付单号")
logger.LogWarn(ctx, fmt.Sprintf("Stripe 完成订单时缺少订单号 client_ip=%s", callerIp))
return
}
@@ -268,65 +270,60 @@ func fulfillOrder(event stripe.Event, referenceId string, customerId string, cal
"currency": strings.ToUpper(event.GetObjectValue("currency")),
"event_type": string(event.Type),
}
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil {
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload), model.PaymentMethodStripe); err == nil {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
log.Println("complete subscription order failed:", err.Error(), referenceId)
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
return
}
err := model.Recharge(referenceId, customerId, callerIp)
if err != nil {
log.Println(err.Error(), referenceId)
logger.LogError(ctx, fmt.Sprintf("Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
return
}
total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
currency := strings.ToUpper(event.GetObjectValue("currency"))
log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency)
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
}
func sessionExpired(event stripe.Event) {
func sessionExpired(ctx context.Context, event stripe.Event) {
referenceId := event.GetObjectValue("client_reference_id")
status := event.GetObjectValue("status")
if "expired" != status {
log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId)
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s", referenceId, status))
return
}
if len(referenceId) == 0 {
log.Println("未提供支付单号")
logger.LogWarn(ctx, "Stripe checkout.expired 缺少订单号")
return
}
// Subscription order expiration
LockOrder(referenceId)
defer UnlockOrder(referenceId)
if err := model.ExpireSubscriptionOrder(referenceId); err == nil {
if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentMethodStripe); err == nil {
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单已过期 trade_no=%s", referenceId))
return
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
return
}
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Println("充值订单不存在", referenceId)
err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentMethodStripe, common.TopUpStatusExpired)
if errors.Is(err, model.ErrTopUpNotFound) {
logger.LogWarn(ctx, fmt.Sprintf("Stripe 充值订单不存在,无法标记过期 trade_no=%s", referenceId))
return
}
if topUp.Status != common.TopUpStatusPending {
log.Println("充值订单状态错误", referenceId)
}
topUp.Status = common.TopUpStatusExpired
err := topUp.Update()
if err != nil {
log.Println("过期充值订单失败", referenceId, ", err:", err.Error())
logger.LogError(ctx, fmt.Sprintf("Stripe 充值订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
return
}
log.Println("充值订单已过期", referenceId)
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已过期 trade_no=%s", referenceId))
}
// genStripeLink generates a Stripe Checkout session URL for payment.
+72 -35
View File
@@ -1,14 +1,15 @@
package controller
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"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"
@@ -99,28 +100,57 @@ type WaffoPayRequest struct {
PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
}
func RequestWaffoAmount(c *gin.Context) {
var req WaffoPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
waffoMinTopup := int64(setting.WaffoMinTopUp)
if req.Amount < waffoMinTopup {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
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 := getWaffoPayMoney(float64(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": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
// RequestWaffoPay 创建 Waffo 支付订单
func RequestWaffoPay(c *gin.Context) {
if !setting.WaffoEnabled {
c.JSON(200, gin.H{"message": "error", "data": "Waffo 支付未启用"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo 支付未启用"})
return
}
var req WaffoPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
return
}
waffoMinTopup := int64(setting.WaffoMinTopUp)
if req.Amount < waffoMinTopup {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(200, gin.H{"message": "error", "data": "用户不存在"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
return
}
@@ -131,8 +161,8 @@ func RequestWaffoPay(c *gin.Context) {
// 新协议:按索引查找
idx := *req.PayMethodIndex
if idx < 0 || idx >= len(methods) {
log.Printf("Waffo 无效的支付方式索引: %d, UserId=%d, 可用范围: [0, %d)", idx, id, len(methods))
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
return
}
resolvedPayMethodType = methods[idx].PayMethodType
@@ -149,8 +179,8 @@ func RequestWaffoPay(c *gin.Context) {
}
}
if !valid {
log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id)
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
return
}
}
@@ -159,7 +189,7 @@ func RequestWaffoPay(c *gin.Context) {
group, _ := model.GetUserGroup(id, true)
payMoney := getWaffoPayMoney(float64(req.Amount), group)
if payMoney < 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
return
}
@@ -182,22 +212,22 @@ func RequestWaffoPay(c *gin.Context) {
Amount: amount,
Money: payMoney,
TradeNo: merchantOrderId,
PaymentMethod: "waffo",
PaymentMethod: model.PaymentMethodWaffo,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
log.Printf("Waffo 创建本地订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}
sdk, err := getWaffoSDK()
if err != nil {
log.Printf("Waffo SDK 初始化失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"})
return
}
@@ -238,29 +268,29 @@ func RequestWaffoPay(c *gin.Context) {
}
resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
if err != nil {
log.Printf("Waffo 创建订单失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建订单失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
if !resp.IsSuccess() {
log.Printf("Waffo 创建订单业务失败: [%s] %s, 完整响应: %+v", resp.Code, resp.Message, resp)
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
orderData := resp.GetData()
log.Printf("Waffo 订单创建成功 - 用户: %d, 订单: %s, 金额: %.2f", id, merchantOrderId, payMoney)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
paymentUrl := orderData.FetchRedirectURL()
if paymentUrl == "" {
paymentUrl = orderData.OrderAction
}
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "success",
"data": gin.H{
"payment_url": paymentUrl,
@@ -287,16 +317,22 @@ type webhookSubscriptionInfo struct {
// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
func WaffoWebhook(c *gin.Context) {
if !isWaffoWebhookEnabled() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("Waffo Webhook 读取 body 失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusBadRequest)
return
}
sdk, err := getWaffoSDK()
if err != nil {
log.Printf("Waffo Webhook SDK 初始化失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
@@ -304,17 +340,18 @@ func WaffoWebhook(c *gin.Context) {
wh := sdk.Webhook()
bodyStr := string(bodyBytes)
signature := c.GetHeader("X-SIGNATURE")
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
// 验证请求签名
if !wh.VerifySignature(bodyStr, signature) {
log.Printf("Waffo webhook 签名验证失败")
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
c.AbortWithStatus(http.StatusBadRequest)
return
}
var event core.WebhookEvent
if err := common.Unmarshal(bodyBytes, &event); err != nil {
log.Printf("Waffo Webhook 解析失败: %v", err)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
sendWaffoWebhookResponse(c, wh, false, "invalid payload")
return
}
@@ -324,14 +361,14 @@ func WaffoWebhook(c *gin.Context) {
// 解析为扩展类型,区分普通支付和订阅支付
var payload webhookPayloadWithSubInfo
if err := common.Unmarshal(bodyBytes, &payload); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
return
}
log.Printf("Waffo Webhook - EventType: %s, MerchantOrderId: %s, OrderStatus: %s",
event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
default:
log.Printf("Waffo Webhook 未知事件: %s", event.EventType)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 忽略事件 event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
sendWaffoWebhookResponse(c, wh, true, "")
}
}
@@ -339,13 +376,13 @@ func WaffoWebhook(c *gin.Context) {
// handleWaffoPayment 处理支付完成通知
func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
if result.OrderStatus != "PAY_SUCCESS" {
log.Printf("Waffo 订单状态非成功: %s, 订单: %s", result.OrderStatus, result.MerchantOrderID)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
// 终态失败订单标记为 failed,避免永远停在 pending
if result.MerchantOrderID != "" {
if topUp := model.GetTopUpByTradeNo(result.MerchantOrderID); topUp != nil &&
topUp.Status == common.TopUpStatusPending {
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
if err := model.UpdatePendingTopUpStatus(result.MerchantOrderID, model.PaymentMethodWaffo, common.TopUpStatusFailed); err != nil &&
!errors.Is(err, model.ErrTopUpNotFound) &&
!errors.Is(err, model.ErrTopUpStatusInvalid) {
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
}
}
sendWaffoWebhookResponse(c, wh, true, "")
@@ -358,12 +395,12 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
defer UnlockOrder(merchantOrderId)
if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
sendWaffoWebhookResponse(c, wh, false, err.Error())
return
}
log.Printf("Waffo 充值成功 - 订单: %s", merchantOrderId)
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值成功 trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
sendWaffoWebhookResponse(c, wh, true, "")
}
+259
View File
@@ -0,0 +1,259 @@
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")
}
+91
View File
@@ -0,0 +1,91 @@
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)
})
}
}
+22
View File
@@ -5,6 +5,28 @@ import (
"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
func (i *IntValue) UnmarshalJSON(b []byte) error {
+36
View File
@@ -106,6 +106,18 @@ func InitOptionMap() {
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
common.OptionMap["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["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -407,6 +419,30 @@ func updateOptionMap(key string, value string) (err error) {
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
case "WaffoMinTopUp":
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":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":
+172
View File
@@ -0,0 +1,172 @@
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)
}
+10 -2
View File
@@ -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.
func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedPaymentMethod string) error {
if tradeNo == "" {
return errors.New("tradeNo is empty")
}
@@ -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 {
return ErrSubscriptionOrderNotFound
}
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if order.Status == common.TopUpStatusSuccess {
return nil
}
@@ -596,6 +599,8 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
topup.Money = order.Money
if topup.PaymentMethod == "" {
topup.PaymentMethod = order.PaymentMethod
} else if topup.PaymentMethod != order.PaymentMethod {
return ErrPaymentMethodMismatch
}
if topup.CreateTime == 0 {
topup.CreateTime = order.CreateTime
@@ -605,7 +610,7 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
return tx.Save(&topup).Error
}
func ExpireSubscriptionOrder(tradeNo string) error {
func ExpireSubscriptionOrder(tradeNo string, expectedPaymentMethod string) error {
if tradeNo == "" {
return errors.New("tradeNo is empty")
}
@@ -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 {
return ErrSubscriptionOrderNotFound
}
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
return ErrPaymentMethodMismatch
}
if order.Status != common.TopUpStatusPending {
return nil
}
+15 -1
View File
@@ -33,7 +33,17 @@ func TestMain(m *testing.M) {
}
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())
}
@@ -48,6 +58,10 @@ func truncateTables(t *testing.T) {
DB.Exec("DELETE FROM tokens")
DB.Exec("DELETE FROM logs")
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")
})
}
+107 -8
View File
@@ -23,7 +23,18 @@ type TopUp struct {
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 {
var err error
@@ -57,6 +68,33 @@ func GetTopUpByTradeNo(tradeNo string) *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) {
if referenceId == "" {
return errors.New("未提供支付单号")
@@ -76,7 +114,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != "stripe" {
if topUp.PaymentMethod != PaymentMethodStripe {
return ErrPaymentMethodMismatch
}
@@ -105,7 +143,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
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
}
@@ -302,7 +340,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
// 计算应充值额度:
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
if topUp.PaymentMethod == "stripe" {
if topUp.PaymentMethod == PaymentMethodStripe {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
} else {
@@ -359,7 +397,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != "creem" {
if topUp.PaymentMethod != PaymentMethodCreem {
return ErrPaymentMethodMismatch
}
@@ -410,7 +448,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
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
}
@@ -434,7 +472,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != "waffo" {
if topUp.PaymentMethod != PaymentMethodWaffo {
return ErrPaymentMethodMismatch
}
@@ -472,7 +510,68 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
}
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
+4
View File
@@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
@@ -90,7 +91,10 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
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-pancake/amount", controller.RequestWaffoPancakeAmount)
//selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
+2
View File
@@ -42,6 +42,7 @@ func TestMain(m *testing.M) {
&model.Token{},
&model.Log{},
&model.Channel{},
&model.TopUp{},
&model.UserSubscription{},
); err != nil {
panic("failed to migrate: " + err.Error())
@@ -62,6 +63,7 @@ func truncate(t *testing.T) {
model.DB.Exec("DELETE FROM tokens")
model.DB.Exec("DELETE FROM logs")
model.DB.Exec("DELETE FROM channels")
model.DB.Exec("DELETE FROM top_ups")
model.DB.Exec("DELETE FROM user_subscriptions")
})
}
+398
View File
@@ -0,0 +1,398 @@
package service
import (
"bytes"
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
)
const (
waffoPancakeAuthBaseURL = "https://waffo-pancake-auth-service.vercel.app"
waffoPancakeCheckoutPath = "/v1/actions/checkout/create-session"
waffoPancakeDefaultTolerance = 5 * time.Minute
)
type WaffoPancakePriceSnapshot struct {
Amount string `json:"amount"`
TaxIncluded bool `json:"taxIncluded"`
TaxCategory string `json:"taxCategory"`
}
type WaffoPancakeCreateSessionParams struct {
StoreID string `json:"storeId"`
ProductID string `json:"productId"`
ProductType string `json:"productType"`
Currency string `json:"currency"`
PriceSnapshot *WaffoPancakePriceSnapshot `json:"priceSnapshot,omitempty"`
BuyerEmail string `json:"buyerEmail,omitempty"`
SuccessURL string `json:"successUrl,omitempty"`
ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"`
}
type WaffoPancakeCheckoutSession struct {
SessionID string `json:"sessionId"`
CheckoutURL string `json:"checkoutUrl"`
ExpiresAt string `json:"expiresAt"`
OrderID string `json:"orderId"`
}
type waffoPancakeAPIError struct {
Message string `json:"message"`
Layer string `json:"layer"`
}
type waffoPancakeCreateSessionResponse struct {
Data *WaffoPancakeCheckoutSession `json:"data"`
Errors []waffoPancakeAPIError `json:"errors"`
}
type waffoPancakeWebhookData struct {
ID string `json:"id"`
OrderID string `json:"orderId"`
BuyerEmail string `json:"buyerEmail"`
Currency string `json:"currency"`
Amount dto.StringValue `json:"amount"`
TaxAmount dto.StringValue `json:"taxAmount"`
ProductName string `json:"productName"`
}
type waffoPancakeWebhookEvent struct {
ID string `json:"id"`
Timestamp string `json:"timestamp"`
EventType string `json:"eventType"`
EventID string `json:"eventId"`
StoreID string `json:"storeId"`
Mode string `json:"mode"`
Data waffoPancakeWebhookData `json:"data"`
}
func (e *waffoPancakeWebhookEvent) NormalizedEventType() string {
if e == nil {
return ""
}
return e.EventType
}
func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancakeCreateSessionParams) (*WaffoPancakeCheckoutSession, error) {
if params == nil {
return nil, fmt.Errorf("missing checkout params")
}
body, err := common.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshal Waffo Pancake checkout payload: %w", err)
}
privateKey, err := normalizeRSAPrivateKey(setting.WaffoPancakePrivateKey)
if err != nil {
return nil, err
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
signature, err := signWaffoPancakeRequest(http.MethodPost, waffoPancakeCheckoutPath, timestamp, string(body), privateKey)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, waffoPancakeAuthBaseURL+waffoPancakeCheckoutPath, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build Waffo Pancake checkout request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Merchant-Id", setting.WaffoPancakeMerchantID)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Signature", signature)
if setting.WaffoPancakeSandbox {
req.Header.Set("X-Environment", "test")
} else {
req.Header.Set("X-Environment", "prod")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request Waffo Pancake checkout session: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read Waffo Pancake checkout response: %w", err)
}
var result waffoPancakeCreateSessionResponse
if err := common.Unmarshal(responseBody, &result); err != nil {
return nil, fmt.Errorf("decode Waffo Pancake checkout response: %w", err)
}
if resp.StatusCode >= http.StatusBadRequest {
if len(result.Errors) > 0 {
return nil, fmt.Errorf("Waffo Pancake error (%d): %s", resp.StatusCode, result.Errors[0].Message)
}
return nil, fmt.Errorf("Waffo Pancake checkout request failed with status %d", resp.StatusCode)
}
if len(result.Errors) > 0 {
return nil, fmt.Errorf("Waffo Pancake error: %s", result.Errors[0].Message)
}
if result.Data == nil || result.Data.CheckoutURL == "" || strings.TrimSpace(result.Data.SessionID) == "" {
return nil, fmt.Errorf("Waffo Pancake returned empty checkout session")
}
return result.Data, nil
}
func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) {
environment := resolveWaffoPancakeWebhookEnvironment(payload)
return verifyWaffoPancakeWebhook(payload, signatureHeader, environment)
}
func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) {
if event == nil {
return "", fmt.Errorf("missing webhook event")
}
if tradeNo := strings.TrimSpace(event.Data.OrderID); tradeNo != "" {
topUp := model.GetTopUpByTradeNo(tradeNo)
if topUp != nil && topUp.PaymentMethod == model.PaymentMethodWaffoPancake {
return tradeNo, nil
}
return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo)
}
return "", fmt.Errorf("missing webhook orderId")
}
func normalizeRSAPrivateKey(raw string) (string, error) {
return normalizePEMKey(raw, "PRIVATE KEY", "RSA PRIVATE KEY")
}
func normalizeRSAPublicKey(raw string) (string, error) {
return normalizePEMKey(raw, "PUBLIC KEY", "RSA PUBLIC KEY")
}
func normalizePEMKey(raw string, pkcs8Type string, pkcs1Type string) (string, error) {
if strings.TrimSpace(raw) == "" {
return "", fmt.Errorf("%s is empty", strings.ToLower(pkcs8Type))
}
normalized := strings.TrimSpace(strings.ReplaceAll(raw, `\n`, "\n"))
if strings.Contains(normalized, "BEGIN ") {
block, _ := pem.Decode([]byte(normalized))
if block == nil {
return "", fmt.Errorf("invalid PEM encoded %s", strings.ToLower(pkcs8Type))
}
return string(pem.EncodeToMemory(block)), nil
}
der, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(normalized, "\n", ""))
if err != nil {
return "", fmt.Errorf("invalid base64 encoded %s: %w", strings.ToLower(pkcs8Type), err)
}
pemType := pkcs8Type
if pkcs8Type == "PRIVATE KEY" {
if _, err := x509.ParsePKCS8PrivateKey(der); err != nil {
if _, err := x509.ParsePKCS1PrivateKey(der); err == nil {
pemType = pkcs1Type
} else {
return "", fmt.Errorf("invalid RSA private key")
}
}
} else {
if _, err := x509.ParsePKIXPublicKey(der); err != nil {
if _, err := x509.ParsePKCS1PublicKey(der); err == nil {
pemType = pkcs1Type
} else {
return "", fmt.Errorf("invalid RSA public key")
}
}
}
return string(pem.EncodeToMemory(&pem.Block{Type: pemType, Bytes: der})), nil
}
func signWaffoPancakeRequest(method string, path string, timestamp string, body string, privateKeyPEM string) (string, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("invalid RSA private key PEM")
}
var privateKey *rsa.PrivateKey
switch block.Type {
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse PKCS#8 private key: %w", err)
}
parsed, ok := key.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not RSA")
}
privateKey = parsed
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse PKCS#1 private key: %w", err)
}
privateKey = key
default:
return "", fmt.Errorf("unsupported private key type: %s", block.Type)
}
canonicalRequest := buildWaffoPancakeCanonicalRequest(method, path, timestamp, body)
digest := sha256.Sum256([]byte(canonicalRequest))
signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("sign Waffo Pancake request: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
func buildWaffoPancakeCanonicalRequest(method string, path string, timestamp string, body string) string {
bodyHash := sha256.Sum256([]byte(body))
return fmt.Sprintf(
"%s\n%s\n%s\n%s",
strings.ToUpper(method),
path,
timestamp,
base64.StdEncoding.EncodeToString(bodyHash[:]),
)
}
func verifyWaffoPancakeWebhook(payload string, signatureHeader string, environment string) (*waffoPancakeWebhookEvent, error) {
if signatureHeader == "" {
return nil, fmt.Errorf("missing X-Waffo-Signature header")
}
timestampPart, signaturePart := parseWaffoPancakeSignatureHeader(signatureHeader)
if timestampPart == "" || signaturePart == "" {
return nil, fmt.Errorf("malformed X-Waffo-Signature header")
}
timestampMs, err := strconv.ParseInt(timestampPart, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid timestamp in X-Waffo-Signature header")
}
if math.Abs(float64(time.Now().UnixMilli()-timestampMs)) > float64(waffoPancakeDefaultTolerance.Milliseconds()) {
return nil, fmt.Errorf("webhook timestamp outside tolerance window")
}
signatureInput := fmt.Sprintf("%s.%s", timestampPart, payload)
if err := verifyWaffoPancakeWebhookWithKey(signatureInput, signaturePart, resolveWaffoPancakeWebhookPublicKey(environment)); err != nil {
return nil, fmt.Errorf("invalid webhook signature")
}
var event waffoPancakeWebhookEvent
if err := common.Unmarshal([]byte(payload), &event); err != nil {
return nil, fmt.Errorf("parse Waffo Pancake webhook payload: %w", err)
}
return &event, nil
}
func parseWaffoPancakeSignatureHeader(header string) (string, string) {
var timestampPart string
var signaturePart string
for _, pair := range strings.Split(header, ",") {
key, value, found := strings.Cut(strings.TrimSpace(pair), "=")
if !found {
continue
}
switch key {
case "t":
timestampPart = value
case "v1":
signaturePart = value
}
}
return timestampPart, signaturePart
}
func resolveWaffoPancakeWebhookEnvironment(payload string) string {
var envelope struct {
Mode string `json:"mode"`
}
if err := common.Unmarshal([]byte(payload), &envelope); err != nil {
if setting.WaffoPancakeSandbox {
return "test"
}
return "prod"
}
switch strings.ToLower(strings.TrimSpace(envelope.Mode)) {
case "test":
return "test"
case "prod":
return "prod"
default:
if setting.WaffoPancakeSandbox {
return "test"
}
return "prod"
}
}
func resolveWaffoPancakeWebhookPublicKey(environment string) string {
if environment == "prod" {
return strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
}
return strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
}
func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart string, rawPublicKey string) error {
publicKeyPEM, err := normalizeRSAPublicKey(rawPublicKey)
if err != nil {
return err
}
block, _ := pem.Decode([]byte(publicKeyPEM))
if block == nil {
return fmt.Errorf("invalid RSA public key PEM")
}
var publicKey *rsa.PublicKey
switch block.Type {
case "PUBLIC KEY":
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("parse PKIX public key: %w", err)
}
parsed, ok := key.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("public key is not RSA")
}
publicKey = parsed
case "RSA PUBLIC KEY":
key, err := x509.ParsePKCS1PublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("parse PKCS#1 public key: %w", err)
}
publicKey = key
default:
return fmt.Errorf("unsupported public key type: %s", block.Type)
}
signature, err := base64.StdEncoding.DecodeString(signaturePart)
if err != nil {
return fmt.Errorf("decode webhook signature: %w", err)
}
digest := sha256.Sum256([]byte(signatureInput))
if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil {
return fmt.Errorf("verify webhook signature: %w", err)
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
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)
})
}
}
+16
View File
@@ -0,0 +1,16 @@
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
)
+75 -15
View File
@@ -18,12 +18,13 @@ For commercial licensing, please contact support@quantumnous.com
*/
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 SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
import SettingsPaymentGatewayWaffoPancake from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
@@ -48,6 +49,17 @@ const PaymentSetting = () => {
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
});
let [loading, setLoading] = useState(false);
@@ -96,8 +108,21 @@ const PaymentSetting = () => {
case 'MinTopUp':
case 'StripeUnitPrice':
case 'StripeMinTopUp':
case 'WaffoPancakeUnitPrice':
case 'WaffoPancakeMinTopUp':
newInputs[item.key] = parseFloat(item.value);
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:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value);
@@ -108,7 +133,7 @@ const PaymentSetting = () => {
}
});
setInputs(newInputs);
setInputs((prev) => ({ ...prev, ...newInputs }));
} else {
showError(t(message));
}
@@ -133,19 +158,54 @@ const PaymentSetting = () => {
<>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<SettingsGeneralPayment options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayWaffo options={inputs} refresh={onRefresh} />
<Tabs
type='card'
defaultActiveKey='general'
contentStyle={{ paddingTop: 24 }}
>
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
<SettingsGeneralPayment
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<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>
</Spin>
</>
+59 -57
View File
@@ -21,7 +21,6 @@ import React, { useEffect, useRef, useState } from 'react';
import {
Avatar,
Typography,
Tag,
Card,
Button,
Banner,
@@ -88,8 +87,7 @@ const RechargeCard = ({
topupInfo,
onOpenHistory,
enableWaffoTopUp,
waffoTopUp,
waffoPayMethods,
enableWaffoPancakeTopUp,
subscriptionLoading = false,
subscriptionPlans = [],
billingPreference,
@@ -105,6 +103,7 @@ const RechargeCard = ({
const [activeTab, setActiveTab] = useState('topup');
const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0;
const regularPayMethods = payMethods || [];
useEffect(() => {
if (initialTabSetRef.current) return;
@@ -227,19 +226,31 @@ const RechargeCard = ({
<div className='py-8 flex justify-center'>
<Spin size='large' />
</div>
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? (
) : enableOnlineTopUp ||
enableStripeTopUp ||
enableCreemTopUp ||
enableWaffoTopUp ||
enableWaffoPancakeTopUp ? (
<Form
getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }}
>
<div className='space-y-6'>
{(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && (
{(enableOnlineTopUp ||
enableStripeTopUp ||
enableWaffoTopUp ||
enableWaffoPancakeTopUp) && (
<Row gutter={12}>
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.InputNumber
field='topUpCount'
label={t('充值数量')}
disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWaffoTopUp}
disabled={
!enableOnlineTopUp &&
!enableStripeTopUp &&
!enableWaffoTopUp &&
!enableWaffoPancakeTopUp
}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
@@ -291,16 +302,27 @@ const RechargeCard = ({
style={{ width: '100%' }}
/>
</Col>
{payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && (
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}>
{regularPayMethods.length > 0 && (
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}>
<Space wrap>
{payMethods.filter(m => m.type !== 'waffo').map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0;
{regularPayMethods.map((payMethod) => {
const minTopupVal =
Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const isWaffo =
typeof payMethod.type === 'string' &&
payMethod.type.startsWith('waffo:');
const isWaffoPancake =
payMethod.type === 'waffo_pancake';
const disabled =
(!enableOnlineTopUp && !isStripe) ||
(!enableOnlineTopUp &&
!isStripe &&
!isWaffo &&
!isWaffoPancake) ||
(!enableStripeTopUp && isStripe) ||
(!enableWaffoTopUp && isWaffo) ||
(!enableWaffoPancakeTopUp && isWaffoPancake) ||
minTopupVal > Number(topUpCount || 0);
const buttonEl = (
@@ -320,6 +342,21 @@ const RechargeCard = ({
<SiWechat size={18} color='#07C160' />
) : payMethod.type === 'stripe' ? (
<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
size={18}
@@ -355,8 +392,8 @@ const RechargeCard = ({
);
})}
</Space>
</Form.Slot>
</Col>
</Form.Slot>
</Col>
)}
</Row>
)}
@@ -388,7 +425,9 @@ const RechargeCard = ({
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{presetAmounts.map((preset, index) => {
const discount =
preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
preset.discount ||
topupInfo?.discount?.[preset.value] ||
1.0;
const originalPrice = preset.value * priceRatio;
const discountedPrice = originalPrice * discount;
const hasDiscount = discount < 1.0;
@@ -404,7 +443,7 @@ const RechargeCard = ({
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7;
}
} catch (e) { }
} catch (e) {}
let displayValue = preset.value; // 显示的数量
let displayActualPay = actualPay;
@@ -455,7 +494,10 @@ const RechargeCard = ({
{hasDiscount && (
<Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off')
? ((1 - parseFloat(discount)) * 100).toFixed(1)
? (
(1 - parseFloat(discount)) *
100
).toFixed(1)
: (discount * 10).toFixed(1)}
{t('折')}
</Tag>
@@ -482,46 +524,6 @@ const RechargeCard = ({
</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 充值区域 */}
{enableCreemTopUp && creemProducts.length > 0 && (
<Form.Slot label={t('Creem 充值')}>
+195 -35
View File
@@ -75,6 +75,8 @@ const TopUp = () => {
const [enableWaffoTopUp, setEnableWaffoTopUp] = useState(false);
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
const [waffoMinTopUp, setWaffoMinTopUp] = useState(1);
const [enableWaffoPancakeTopUp, setEnableWaffoPancakeTopUp] = useState(false);
const [waffoPancakeMinTopUp, setWaffoPancakeMinTopUp] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
@@ -112,6 +114,39 @@ const TopUp = () => {
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 () => {
if (redemptionCode === '') {
showInfo(t('请输入兑换码!'));
@@ -162,6 +197,16 @@ const TopUp = () => {
showError(t('管理员未开启Stripe充值!'));
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 {
if (!enableOnlineTopUp) {
showError(t('管理员未开启在线充值!'));
@@ -172,14 +217,11 @@ const TopUp = () => {
setPayWay(payment);
setPaymentLoading(true);
try {
if (payment === 'stripe') {
await getStripeAmount();
} else {
await getAmount();
}
const selectedMinTopUp = getPaymentMinTopUp(payment);
await requestAmountByPayment(payment);
if (topUpCount < minTopUp) {
showError(t('充值数量不能小于') + minTopUp);
if (topUpCount < selectedMinTopUp) {
showError(t('充值数量不能小于') + selectedMinTopUp);
return;
}
setOpen(true);
@@ -191,6 +233,29 @@ const TopUp = () => {
};
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') {
// Stripe 支付处理
if (amount === 0) {
@@ -317,32 +382,122 @@ const TopUp = () => {
const waffoTopUp = async (payMethodIndex) => {
try {
if (topUpCount < waffoMinTopUp) {
showError(t('充值数量不能小于') + waffoMinTopUp);
return;
}
setPaymentLoading(true);
const requestBody = {
amount: parseInt(topUpCount),
};
if (payMethodIndex != null) {
requestBody.pay_method_index = payMethodIndex;
}
const res = await API.post('/api/user/waffo/pay', requestBody);
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success' && data?.payment_url) {
window.open(data.payment_url, '_blank');
} else {
showError(data || t('支付请求失败'));
}
if (topUpCount < waffoMinTopUp) {
showError(t('充值数量不能小于') + waffoMinTopUp);
return;
}
setPaymentLoading(true);
const requestBody = {
amount: parseInt(topUpCount),
};
if (payMethodIndex != null) {
requestBody.pay_method_index = payMethodIndex;
}
const res = await API.post('/api/user/waffo/pay', requestBody);
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success' && data?.payment_url) {
window.open(data.payment_url, '_blank');
} else {
showError(res);
showError(data || t('支付请求失败'));
}
} else {
showError(res);
}
} catch (e) {
showError(t('支付请求失败'));
showError(t('支付请求失败'));
} finally {
setPaymentLoading(false);
setPaymentLoading(false);
}
};
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);
}
};
@@ -481,20 +636,26 @@ const TopUp = () => {
const enableStripeTopUp = data.enable_stripe_topup || false;
const enableOnlineTopUp = data.enable_online_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
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: data.enable_waffo_topup
: enableWaffoTopUp
? data.waffo_min_topup
: enableWaffoPancakeTopUp
? data.waffo_pancake_min_topup
: 1;
setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp);
setEnableCreemTopUp(enableCreemTopUp);
const enableWaffoTopUp = data.enable_waffo_topup || false;
setEnableWaffoTopUp(enableWaffoTopUp);
setWaffoPayMethods(data.waffo_pay_methods || []);
setWaffoMinTopUp(data.waffo_min_topup || 1);
setEnableWaffoPancakeTopUp(enableWaffoPancakeTopUp);
setWaffoPancakeMinTopUp(data.waffo_pancake_min_topup || 1);
setMinTopUp(minTopUpValue);
setTopUpCount(minTopUpValue);
@@ -739,7 +900,7 @@ const TopUp = () => {
amountLoading={amountLoading}
renderAmount={renderAmount}
payWay={payWay}
payMethods={payMethods}
payMethods={confirmPayMethods}
amountNumber={amount}
discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
/>
@@ -789,8 +950,7 @@ const TopUp = () => {
creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp}
enableWaffoTopUp={enableWaffoTopUp}
waffoTopUp={waffoTopUp}
waffoPayMethods={waffoPayMethods}
enableWaffoPancakeTopUp={enableWaffoPancakeTopUp}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}
@@ -804,7 +964,7 @@ const TopUp = () => {
setSelectedPreset={setSelectedPreset}
renderAmount={renderAmount}
amountLoading={amountLoading}
payMethods={payMethods}
payMethods={confirmPayMethods}
preTopUp={preTopUp}
paymentLoading={paymentLoading}
payWay={payWay}
@@ -140,6 +140,17 @@ const PaymentConfirmModal = ({
size={16}
color='#635BFF'
/>
) : payMethod.icon ? (
<img
src={payMethod.icon}
alt={payMethod.name}
className='mr-2'
style={{
width: 16,
height: 16,
objectFit: 'contain',
}}
/>
) : (
<CreditCard
className='mr-2'
+8 -4
View File
@@ -14,7 +14,9 @@
",点击更新": ", click Update",
"(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{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}} 条)_other": "(Showing {{count}} items after filtering)",
"(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(Input {{input}} tokens / 1M tokens * {{symbol}}{{price}}",
@@ -743,6 +745,8 @@
"最低": "lowest",
"最低充值数量": "",
"最低充值美元数量": "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 Updated",
"最后请求": "Last request",
@@ -1044,7 +1048,7 @@
"响应缺少凭据": "Response missing credentials",
"响应缺少授权链接": "Response missing authorization link",
"商品价格 ID": "Product Price ID",
"商户 ID": "",
"商户 ID": "Merchant ID",
"回答内容": "Answer Content",
"回调 URL 填": "Callback URL Fill",
"回调 URL 格式": "Callback URL format",
@@ -1712,7 +1716,7 @@
"支付渠道": "Payment Channels",
"支付设置": "Payment",
"支付请求失败": "Payment request failed",
"支付返回地址": "",
"支付返回地址": "Return URL",
"支付金额": "Payment Amount",
"支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images",
"支持 JSONPath,如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email",
@@ -2212,7 +2216,7 @@
"永久删除您的两步验证设置": "Permanently delete your two-factor authentication settings",
"永久删除所有备用码(包括未使用的)": "Permanently delete all backup codes (including unused ones)",
"汇率": "Exchange rate",
"沙盒模式": "",
"沙盒模式": "Sandbox Mode",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Sandbox RSA private key Base64 (PKCS#8 DER)",
"沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Sandbox Waffo public key Base64 (X.509 DER)",
+8 -4
View File
@@ -16,7 +16,9 @@
",点击更新": ", cliquez sur Mettre à jour",
"(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{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}} 条)_many": "(Affichage de {{count}} éléments après filtrage)",
"(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)",
@@ -742,6 +744,8 @@
"最低": "Le plus bas",
"最低充值数量": "",
"最低充值美元数量": "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",
"最后更新": "Last Updated",
"最后请求": "Dernière requête",
@@ -1042,7 +1046,7 @@
"响应缺少凭据": "Identifiants manquants dans la réponse",
"响应缺少授权链接": "Lien d'autorisation manquant dans la réponse",
"商品价格 ID": "ID du prix du produit",
"商户 ID": "",
"商户 ID": "ID marchand",
"回答内容": "Contenu de la réponse",
"回调 URL 填": "Remplir l'URL de rappel",
"回调 URL 格式": "Format de l'URL de rappel",
@@ -1712,7 +1716,7 @@
"支付渠道": "Canaux de paiement",
"支付设置": "Paiement",
"支付请求失败": "Échec de la demande de paiement",
"支付返回地址": "",
"支付返回地址": "URL de retour",
"支付金额": "Montant payé",
"支持 Ctrl+V 粘贴图片": "Supporte Ctrl+V pour coller l'image",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Prend en charge le code de vérification TOTP à 6 chiffres ou le code de sauvegarde à 8 chiffres, peut être configuré ou consulté dans `Paramètres personnels - Paramètres de sécurité - Paramètres d'authentification à deux facteurs`.",
@@ -2203,7 +2207,7 @@
"永久删除您的两步验证设置": "Supprimer définitivement vos paramètres d'authentification à deux facteurs",
"永久删除所有备用码(包括未使用的)": "Supprimer définitivement tous les codes de sauvegarde (y compris ceux non utilisés)",
"汇率": "Taux de change",
"沙盒模式": "",
"沙盒模式": "Mode bac à sable",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Clé privée RSA Base64 (PKCS#8 DER) de sandbox",
"沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Clé publique Waffo Base64 (X.509 DER) de sandbox",
+8 -4
View File
@@ -14,7 +14,9 @@
",点击更新": "、クリックして更新してください",
"(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "",
"当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "現在Epay APIのみ対応しています。デフォルトで、上記のサーバーURLがコールバックアドレスとして使用されます。)",
"当前仅支持易支付接口,回调地址请在通用设置中配置。": "現在Epay API のみ対応しています。コールバックアドレスは一般設定で設定してください。",
"请确认商户和所选环境密钥一致。": "加盟店情報と選択中の環境の鍵が一致していることを確認してください。",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "Merchant、Store、Product と選択中の環境の鍵が一致していることを確認してください。",
"(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)",
"(输入 {{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}}",
@@ -729,6 +731,8 @@
"最低": "最低",
"最低充值数量": "",
"最低充值美元数量": "最低チャージUSD額",
"最低充值美元数量必须大于 0": "最低チャージUSD額は 0 より大きい必要があります",
"留空则自动使用当前站点的默认回调地址": "空欄の場合は現在のサイトのデフォルトのコールバックアドレスを使用します",
"最后使用时间": "最終利用日時",
"最后更新": "Last Updated",
"最后请求": "最終リクエスト日時",
@@ -1029,7 +1033,7 @@
"响应缺少凭据": "レスポンスに資格情報がありません",
"响应缺少授权链接": "レスポンスに認可リンクがありません",
"商品价格 ID": "料金ID",
"商户 ID": "",
"商户 ID": "加盟店 ID",
"回答内容": "回答",
"回调 URL 填": "コールバックURLを入力してください",
"回调 URL 格式": "コールバックURL形式",
@@ -1683,7 +1687,7 @@
"支付渠道": "決済チャネル",
"支付设置": "決済",
"支付请求失败": "決済リクエストに失敗しました",
"支付返回地址": "",
"支付返回地址": "返却先 URL",
"支付金额": "決済金額",
"支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "6桁のTOTP認証コードまたは8桁のバックアップコードに対応しています。`アカウント設定 - セキュリティ設定 - 2要素認証設定`で設定または確認できます。",
@@ -2174,7 +2178,7 @@
"永久删除您的两步验证设置": "2要素認証設定を永久に削除",
"永久删除所有备用码(包括未使用的)": "すべてのバックアップコード(未使用分を含む)を永久に削除",
"汇率": "為替レート",
"沙盒模式": "",
"沙盒模式": "サンドボックスモード",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "サンドボックス RSA 秘密鍵 Base64 (PKCS#8 DER)",
"沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "サンドボックス Waffo 公開鍵 Base64 (X.509 DER)",
+8 -4
View File
@@ -18,7 +18,9 @@
",点击更新": ", нажмите для обновления",
"(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "",
"当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(В настоящее время поддерживается только интерфейс YiPay, по умолчанию используется адрес сервера выше в качестве адреса обратного вызова!)",
"当前仅支持易支付接口,回调地址请在通用设置中配置。": "Сейчас поддерживается только интерфейс Epay. Настройте адрес обратного вызова в общих настройках.",
"请确认商户和所选环境密钥一致。": "Убедитесь, что мерчант и ключи выбранной среды совпадают.",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "Убедитесь, что Merchant, Store, Product и ключи выбранной среды совпадают.",
"(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)",
"(筛选后显示 {{count}} 条)_few": "(Показано {{count}} элемента после фильтрации)",
"(筛选后显示 {{count}} 条)_many": "(Показано {{count}} элементов после фильтрации)",
@@ -750,6 +752,8 @@
"最低": "Минимум",
"最低充值数量": "",
"最低充值美元数量": "Минимальная сумма пополнения в долларах",
"最低充值美元数量必须大于 0": "Минимальная сумма пополнения в долларах должна быть больше 0",
"留空则自动使用当前站点的默认回调地址": "Оставьте пустым, чтобы использовать адрес обратного вызова сайта по умолчанию",
"最后使用时间": "Время последнего использования",
"最后更新": "Last Updated",
"最后请求": "Последний запрос",
@@ -1050,7 +1054,7 @@
"响应缺少凭据": "В ответе отсутствуют учётные данные",
"响应缺少授权链接": "В ответе отсутствует ссылка авторизации",
"商品价格 ID": "ID цены товара",
"商户 ID": "",
"商户 ID": "ID мерчанта",
"回答内容": "Содержание ответа",
"回调 URL 填": "URL обратного вызова",
"回调 URL 格式": "Формат URL обратного вызова",
@@ -1730,7 +1734,7 @@
"支付渠道": "Платежные каналы",
"支付设置": "Оплата",
"支付请求失败": "Запрос на оплату не удался",
"支付返回地址": "",
"支付返回地址": "Адрес возврата",
"支付金额": "Сумма оплаты",
"支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Поддерживает 6-значные TOTP коды подтверждения или 8-значные резервные коды, можно настроить или просмотреть в `Личные настройки-Настройки безопасности-Настройки двухфакторной аутентификации`.",
@@ -2221,7 +2225,7 @@
"永久删除您的两步验证设置": "Окончательно удалить настройки двухфакторной аутентификации",
"永久删除所有备用码(包括未使用的)": "Окончательно удалить все резервные коды (включая неиспользованные)",
"汇率": "Обменный курс",
"沙盒模式": "",
"沙盒模式": "Режим песочницы",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "RSA закрытый ключ Base64 (PKCS#8 DER) песочницы",
"沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Открытый ключ Waffo Base64 (X.509 DER) песочницы",
+8 -4
View File
@@ -14,7 +14,9 @@
",点击更新": ", nhấn để cập nhật",
"(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{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)",
"(输入 {{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}}",
@@ -730,6 +732,8 @@
"最低": "thấp nhất",
"最低充值数量": "",
"最低充值美元数量": "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",
"最后更新": "Last Updated",
"最后请求": "Yêu cầu cuối cùng",
@@ -1030,7 +1034,7 @@
"响应缺少凭据": "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",
"商品价格 ID": "ID giá sản phẩm",
"商户 ID": "",
"商户 ID": "ID người bán",
"回答内容": "Nội dung trả lời",
"回调 URL 填": "Điền URL gọi lại",
"回调 URL 格式": "Định dạng URL callback",
@@ -1684,7 +1688,7 @@
"支付渠道": "Kênh thanh toán",
"支付设置": "Thanh toán",
"支付请求失败": "Yêu cầu thanh toán thất bại",
"支付返回地址": "",
"支付返回地址": "URL trả về",
"支付金额": "Số tiền thanh toán",
"支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh",
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Hỗ trợ mã xác minh TOTP 6 chữ số hoặc mã dự phòng 8 chữ số, có thể được cấu hình hoặc xem trong `Cài đặt cá nhân - Cài đặt bảo mật - Cài đặt xác thực hai yếu tố`.",
@@ -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)",
"永久有效": "Có hiệu lực vĩnh viễn",
"汇率": "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",
"沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Khóa công khai Waffo Base64 (X.509 DER) môi trường sandbox",
+45 -1
View File
@@ -12,7 +12,9 @@
",点击更新": ",点击更新",
"(共 {{total}} 个,省略 {{omit}} 个)": "(共 {{total}} 个,省略 {{omit}} 个)",
"(共 {{total}} 个)": "(共 {{total}} 个)",
"当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)",
"当前仅支持易支付接口,回调地址请在通用设置中配置。": "当前仅支持易支付接口,回调地址请在通用设置中配置。",
"请确认商户和所选环境密钥一致。": "请确认商户和所选环境密钥一致。",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "请确认 Merchant、Store、Product 和所选环境密钥一致。",
"(筛选后显示 {{count}} 条)_other": "(筛选后显示 {{count}} 条)",
"(输入 {{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}}",
@@ -718,6 +720,8 @@
"最低": "最低",
"最低充值数量": "最低充值数量",
"最低充值美元数量": "最低充值美元数量",
"最低充值美元数量必须大于 0": "最低充值美元数量必须大于 0",
"留空则自动使用当前站点的默认回调地址": "留空则自动使用当前站点的默认回调地址",
"最后使用时间": "最后使用时间",
"最后更新": "最后更新",
"最后请求": "最后请求",
@@ -1671,6 +1675,7 @@
"支付方式类型": "支付方式类型",
"支付渠道": "支付渠道",
"支付设置": "支付设置",
"易支付设置": "易支付设置",
"支付请求失败": "支付请求失败",
"支付返回地址": "支付返回地址",
"支付金额": "支付金额",
@@ -1895,6 +1900,7 @@
"更新成功": "更新成功",
"更新所有已启用通道余额": "更新所有已启用通道余额",
"更新支付设置": "更新支付设置",
"更新易支付设置": "更新易支付设置",
"更新时间": "更新时间",
"更新服务器地址": "更新服务器地址",
"更新模型信息": "更新模型信息",
@@ -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",
"账户已删除!": "账户已删除!",
"账户已锁定": "账户已锁定",
"账户数据": "账户数据",
+8 -4
View File
@@ -12,7 +12,9 @@
",点击更新": ",點擊更新",
"(共 {{total}} 个,省略 {{omit}} 个)": "",
"(共 {{total}} 个)": "",
"当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(當前僅支援易支付接口,預設使用上方伺服器位址作為回調位址!)",
"当前仅支持易支付接口,回调地址请在通用设置中配置。": "前僅支援易支付接口,回調位址請在通用設定中配置。",
"请确认商户和所选环境密钥一致。": "請確認商戶與所選環境密鑰一致。",
"请确认 Merchant、Store、Product 和所选环境密钥一致。": "請確認 Merchant、Store、Product 與所選環境密鑰一致。",
"(筛选后显示 {{count}} 条)_other": "(篩選後顯示 {{count}} 條)",
"(输入 {{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}}",
@@ -726,6 +728,8 @@
"最低": "最低",
"最低充值数量": "",
"最低充值美元数量": "最低儲值美元數量",
"最低充值美元数量必须大于 0": "最低儲值美元數量必須大於 0",
"留空则自动使用当前站点的默认回调地址": "留空則自動使用目前站點的預設回調位址",
"最后使用时间": "最後使用時間",
"最后更新": "最後更新",
"最后请求": "最後請求",
@@ -1027,7 +1031,7 @@
"响应缺少凭据": "",
"响应缺少授权链接": "",
"商品价格 ID": "商品價格 ID",
"商户 ID": "",
"商户 ID": "商戶 ID",
"回答内容": "回答內容",
"回调 URL 填": "回調 URL 填",
"回调 URL 格式": "回調 URL 格式",
@@ -1683,7 +1687,7 @@
"支付渠道": "支付管道",
"支付设置": "支付設定",
"支付请求失败": "支付請求失敗",
"支付返回地址": "",
"支付返回地址": "支付返回位址",
"支付金额": "支付金額",
"支持 Ctrl+V 粘贴图片": "支援 Ctrl+V 貼上圖片",
"支持 JSONPath,如 email, data.user.email": "支援 JSONPath,如 email, data.user.email",
@@ -2181,7 +2185,7 @@
"永久删除您的两步验证设置": "永久刪除您的兩步驗證設定",
"永久删除所有备用码(包括未使用的)": "永久刪除所有備用碼(包括未使用的)",
"汇率": "匯率",
"沙盒模式": "",
"沙盒模式": "沙盒模式",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "沙盒環境 RSA 私鑰 Base64 (PKCS#8 DER)",
"沙盒环境 Waffo API 密钥": "",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "沙盒環境 Waffo 公鑰 Base64 (X.509 DER)",
@@ -18,29 +18,43 @@ For commercial licensing, please contact support@quantumnous.com
*/
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 {
API,
removeTrailingSlash,
showError,
showSuccess,
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function SettingsGeneralPayment(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('通用设置');
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
ServerAddress: '',
CustomCallbackAddress: '',
TopupGroupRatio: '',
PayMethods: '',
AmountOptions: '',
AmountDiscount: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = {
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);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
}
}, [props.options]);
@@ -49,19 +63,93 @@ export default function SettingsGeneralPayment(props) {
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);
try {
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
const res = await API.put('/api/option/', {
key: 'ServerAddress',
value: ServerAddress,
});
if (res.data.success) {
const options = [
{
key: 'ServerAddress',
value: removeTrailingSlash(inputs.ServerAddress),
},
];
if (inputs.CustomCallbackAddress !== '') {
options.push({
key: 'CustomCallbackAddress',
value: removeTrailingSlash(inputs.CustomCallbackAddress),
});
}
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('更新成功'));
setOriginInputs({ ...inputs });
props.refresh && props.refresh();
} else {
showError(res.data.message);
errorResults.forEach((res) => {
showError(res.data.message);
});
}
} catch (error) {
showError(t('更新失败'));
@@ -76,7 +164,7 @@ export default function SettingsGeneralPayment(props) {
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('通用设置')}>
<Form.Section text={sectionTitle}>
<Form.Input
field='ServerAddress'
label={t('服务器地址')}
@@ -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>
</Spin>
@@ -18,19 +18,19 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import { Button, Form, Row, Col, Typography, Spin } from '@douyinfe/semi-ui';
const { Text } = Typography;
import { Banner, Button, Form, Row, Col, Spin } from '@douyinfe/semi-ui';
import {
API,
removeTrailingSlash,
showError,
showSuccess,
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
export default function SettingsPaymentGateway(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('易支付设置');
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
PayAddress: '',
@@ -38,13 +38,7 @@ export default function SettingsPaymentGateway(props) {
EpayKey: '',
Price: 7.3,
MinTopUp: 1,
TopupGroupRatio: '',
CustomCallbackAddress: '',
PayMethods: '',
AmountOptions: '',
AmountDiscount: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
useEffect(() => {
@@ -61,35 +55,9 @@ export default function SettingsPaymentGateway(props) {
props.options.MinTopUp !== undefined
? parseFloat(props.options.MinTopUp)
: 1,
TopupGroupRatio: props.options.TopupGroupRatio || '',
CustomCallbackAddress: props.options.CustomCallbackAddress || '',
PayMethods: props.options.PayMethods || '',
AmountOptions: props.options.AmountOptions || '',
AmountDiscount: props.options.AmountDiscount || '',
};
// JSON
try {
if (currentInputs.AmountOptions) {
currentInputs.AmountOptions = JSON.stringify(
JSON.parse(currentInputs.AmountOptions),
null,
2,
);
}
} catch {}
try {
if (currentInputs.AmountDiscount) {
currentInputs.AmountDiscount = JSON.stringify(
JSON.parse(currentInputs.AmountDiscount),
null,
2,
);
}
} catch {}
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
}
}, [props.options]);
@@ -104,40 +72,6 @@ export default function SettingsPaymentGateway(props) {
return;
}
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
if (!verifyJSON(inputs.TopupGroupRatio)) {
showError(t('充值分组倍率不是合法的 JSON 字符串'));
return;
}
}
if (originInputs['PayMethods'] !== inputs.PayMethods) {
if (!verifyJSON(inputs.PayMethods)) {
showError(t('充值方式设置不是合法的 JSON 字符串'));
return;
}
}
if (
originInputs['AmountOptions'] !== inputs.AmountOptions &&
inputs.AmountOptions.trim() !== ''
) {
if (!verifyJSON(inputs.AmountOptions)) {
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
return;
}
}
if (
originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
inputs.AmountDiscount.trim() !== ''
) {
if (!verifyJSON(inputs.AmountDiscount)) {
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
return;
}
}
setLoading(true);
try {
const options = [
@@ -156,32 +90,7 @@ export default function SettingsPaymentGateway(props) {
if (inputs.MinTopUp !== '') {
options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() });
}
if (inputs.CustomCallbackAddress !== '') {
options.push({
key: 'CustomCallbackAddress',
value: inputs.CustomCallbackAddress,
});
}
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 requestQueue = options.map((opt) =>
API.put('/api/option/', {
key: opt.key,
@@ -191,7 +100,6 @@ export default function SettingsPaymentGateway(props) {
const results = await Promise.all(requestQueue);
//
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach((res) => {
@@ -199,8 +107,6 @@ export default function SettingsPaymentGateway(props) {
});
} else {
showSuccess(t('更新成功'));
//
setOriginInputs({ ...inputs });
props.refresh && props.refresh();
}
} catch (error) {
@@ -216,12 +122,15 @@ export default function SettingsPaymentGateway(props) {
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('支付设置')}>
<Text>
{t(
'(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)',
<Form.Section text={sectionTitle}>
<Banner
type='info'
icon={<Info size={16} />}
description={t(
'当前仅支持易支付接口,回调地址请在通用设置中配置。',
)}
</Text>
style={{ marginBottom: 16 }}
/>
<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}>
<Form.Input
@@ -233,14 +142,14 @@ export default function SettingsPaymentGateway(props) {
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='EpayId'
label={t('易支付商户ID')}
label={t('商户 ID')}
placeholder={t('例如:0001')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='EpayKey'
label={t('易支付商户密钥')}
label={t('API 密钥')}
placeholder={t('敏感信息不会发送到前端显示')}
type='password'
/>
@@ -250,14 +159,7 @@ export default function SettingsPaymentGateway(props) {
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CustomCallbackAddress'
label={t('回调地址')}
placeholder={t('例如:https://yourdomain.com')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.InputNumber
field='Price'
precision={2}
@@ -265,7 +167,7 @@ export default function SettingsPaymentGateway(props) {
placeholder={t('例如:7,就是7元/美金')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.InputNumber
field='MinTopUp'
label={t('最低充值美元数量')}
@@ -273,58 +175,9 @@ export default function SettingsPaymentGateway(props) {
/>
</Col>
</Row>
<Form.TextArea
field='TopupGroupRatio'
label={t('充值分组倍率')}
placeholder={t('为一个 JSON 文本,键为组名称,值为倍率')}
autosize
/>
<Form.TextArea
field='PayMethods'
label={t('充值方式设置')}
placeholder={t('为一个 JSON 文本')}
autosize
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col span={24}>
<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
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
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={submitPayAddress}>{t('更新支付设置')}</Button>
<Button onClick={submitPayAddress} style={{ marginTop: 16 }}>
{t('更新易支付设置')}
</Button>
</Form.Section>
</Form>
</Spin>
@@ -34,10 +34,11 @@ import {
const { Text } = Typography;
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2 } from 'lucide-react';
import { BookOpen, Plus, Trash2 } from 'lucide-react';
export default function SettingsPaymentGatewayCreem(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('Creem 设置');
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
CreemApiKey: '',
@@ -259,15 +260,22 @@ export default function SettingsPaymentGatewayCreem(props) {
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Creem 设置')}>
<Text>
{t('Creem 介绍')}
<a href='https://creem.io' target='_blank' rel='noreferrer'>
Creem Official Site
</a>
<br />
</Text>
<Banner type='info' description={t('Creem Setting Tips')} />
<Form.Section text={sectionTitle}>
<Banner
type='info'
icon={<BookOpen size={16} />}
description={
<>
{t('Creem 介绍')}
<a href='https://creem.io' target='_blank' rel='noreferrer'>
Creem Official Site
</a>
<br />
{t('Creem Setting Tips')}
</>
}
style={{ marginBottom: 16 }}
/>
<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}>
@@ -281,7 +289,7 @@ export default function SettingsPaymentGatewayCreem(props) {
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemWebhookSecret'
label={t('Webhook 密钥')}
label={t('Webhook 签名密钥')}
placeholder={t(
'用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示',
)}
@@ -291,7 +299,7 @@ export default function SettingsPaymentGatewayCreem(props) {
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='CreemTestMode'
label={t('测试模式')}
label={t('沙盒模式')}
extraText={t('启用后将使用 Creem Test Mode')}
/>
</Col>
@@ -18,16 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import {
Banner,
Button,
Form,
Row,
Col,
Typography,
Spin,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import { Banner, Button, Form, Row, Col, Spin } from '@douyinfe/semi-ui';
import {
API,
removeTrailingSlash,
@@ -35,9 +26,11 @@ import {
showSuccess,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { BookOpen, TriangleAlert } from 'lucide-react';
export default function SettingsPaymentGateway(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('Stripe 设置');
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
StripeApiSecret: '',
@@ -165,42 +158,53 @@ export default function SettingsPaymentGateway(props) {
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Stripe 设置')}>
<Text>
Stripe 密钥Webhook 等设置请
<a
href='https://dashboard.stripe.com/developers'
target='_blank'
rel='noreferrer'
>
点击此处
</a>
进行设置最好先在
<a
href='https://dashboard.stripe.com/test/developers'
target='_blank'
rel='noreferrer'
>
测试环境
</a>
进行测试
<br />
</Text>
<Form.Section text={sectionTitle}>
<Banner
type='info'
description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/stripe/webhook`}
icon={<BookOpen size={16} />}
description={
<>
Stripe 密钥Webhook 等设置请
<a
href='https://dashboard.stripe.com/developers'
target='_blank'
rel='noreferrer'
>
点击此处
</a>
进行设置建议先在
<a
href='https://dashboard.stripe.com/test/developers'
target='_blank'
rel='noreferrer'
>
测试环境
</a>
完成联调
<br />
{t('回调地址')}
{props.options.ServerAddress
? removeTrailingSlash(props.options.ServerAddress)
: t('网站地址')}
/api/stripe/webhook
</>
}
style={{ marginBottom: 12 }}
/>
<Banner
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 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='StripeApiSecret'
label={t('API 密钥')}
placeholder={t(
'sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示',
placeholder={t('例如:sk_xxx 或 rk_xxx,留空表示保持当前不变')}
extraText={t(
'保存后不会回显,请填写当前环境对应的 Stripe API 密钥',
)}
type='password'
/>
@@ -209,7 +213,8 @@ export default function SettingsPaymentGateway(props) {
<Form.Input
field='StripeWebhookSecret'
label={t('Webhook 签名密钥')}
placeholder={t('whsec_xxx 的 Webhook 签名密钥,敏感信息不显示')}
placeholder={t('例如:whsec_xxx,留空表示保持当前不变')}
extraText={t('用于校验 Stripe Webhook 签名,保存后不会回显')}
type='password'
/>
</Col>
@@ -217,7 +222,8 @@ export default function SettingsPaymentGateway(props) {
<Form.Input
field='StripePriceId'
label={t('商品价格 ID')}
placeholder={t('price_xxx 的商品价格 ID,新建产品后可获得')}
placeholder={t('例如:price_xxx')}
extraText={t('在 Stripe 后台创建价格后获得')}
/>
</Col>
</Row>
@@ -231,6 +237,7 @@ export default function SettingsPaymentGateway(props) {
precision={2}
label={t('充值价格(x元/美金)')}
placeholder={t('例如:7,就是7元/美金')}
extraText={t('按 1 美元对应的站内价格填写')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
@@ -238,6 +245,7 @@ export default function SettingsPaymentGateway(props) {
field='StripeMinTopUp'
label={t('最低充值美元数量')}
placeholder={t('例如:2,就是最低充值2$')}
extraText={t('用户单次最少可充值的美元数量')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
@@ -31,13 +31,21 @@ import {
Input,
Space,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../../helpers';
import {
API,
removeTrailingSlash,
showError,
showSuccess,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { BookOpen, TriangleAlert } from 'lucide-react';
const { Text } = Typography;
const toBoolean = (value) => value === true || value === 'true';
export default function SettingsPaymentGatewayWaffo(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle ? undefined : t('Waffo 设置');
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
WaffoEnabled: false,
@@ -55,7 +63,6 @@ export default function SettingsPaymentGatewayWaffo(props) {
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
const iconFileInputRef = useRef(null);
@@ -93,14 +100,14 @@ export default function SettingsPaymentGatewayWaffo(props) {
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = {
WaffoEnabled: props.options.WaffoEnabled === 'true' || props.options.WaffoEnabled === true,
WaffoEnabled: toBoolean(props.options.WaffoEnabled),
WaffoApiKey: props.options.WaffoApiKey || '',
WaffoPrivateKey: props.options.WaffoPrivateKey || '',
WaffoPublicCert: props.options.WaffoPublicCert || '',
WaffoSandboxPublicCert: props.options.WaffoSandboxPublicCert || '',
WaffoSandboxApiKey: props.options.WaffoSandboxApiKey || '',
WaffoSandboxPrivateKey: props.options.WaffoSandboxPrivateKey || '',
WaffoSandbox: props.options.WaffoSandbox === 'true',
WaffoSandbox: toBoolean(props.options.WaffoSandbox),
WaffoMerchantId: props.options.WaffoMerchantId || '',
WaffoCurrency: props.options.WaffoCurrency || 'USD',
WaffoUnitPrice: parseFloat(props.options.WaffoUnitPrice) || 1.0,
@@ -109,7 +116,6 @@ export default function SettingsPaymentGatewayWaffo(props) {
WaffoReturnUrl: props.options.WaffoReturnUrl || '',
};
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
//
@@ -149,15 +155,30 @@ export default function SettingsPaymentGatewayWaffo(props) {
options.push({ key: 'WaffoPrivateKey', value: inputs.WaffoPrivateKey });
}
options.push({ key: 'WaffoPublicCert', value: inputs.WaffoPublicCert || '' });
options.push({ key: 'WaffoSandboxPublicCert', value: inputs.WaffoSandboxPublicCert || '' });
options.push({
key: 'WaffoPublicCert',
value: inputs.WaffoPublicCert || '',
});
options.push({
key: 'WaffoSandboxPublicCert',
value: inputs.WaffoSandboxPublicCert || '',
});
if (inputs.WaffoSandboxApiKey && inputs.WaffoSandboxApiKey !== '') {
options.push({ key: 'WaffoSandboxApiKey', value: inputs.WaffoSandboxApiKey });
options.push({
key: 'WaffoSandboxApiKey',
value: inputs.WaffoSandboxApiKey,
});
}
if (inputs.WaffoSandboxPrivateKey && inputs.WaffoSandboxPrivateKey !== '') {
options.push({ key: 'WaffoSandboxPrivateKey', value: inputs.WaffoSandboxPrivateKey });
if (
inputs.WaffoSandboxPrivateKey &&
inputs.WaffoSandboxPrivateKey !== ''
) {
options.push({
key: 'WaffoSandboxPrivateKey',
value: inputs.WaffoSandboxPrivateKey,
});
}
options.push({
@@ -165,7 +186,10 @@ export default function SettingsPaymentGatewayWaffo(props) {
value: inputs.WaffoSandbox ? 'true' : 'false',
});
options.push({ key: 'WaffoMerchantId', value: inputs.WaffoMerchantId || '' });
options.push({
key: 'WaffoMerchantId',
value: inputs.WaffoMerchantId || '',
});
options.push({ key: 'WaffoCurrency', value: inputs.WaffoCurrency || '' });
options.push({
@@ -178,8 +202,14 @@ export default function SettingsPaymentGatewayWaffo(props) {
value: String(inputs.WaffoMinTopUp || 1),
});
options.push({ key: 'WaffoNotifyUrl', value: inputs.WaffoNotifyUrl || '' });
options.push({ key: 'WaffoReturnUrl', value: inputs.WaffoReturnUrl || '' });
options.push({
key: 'WaffoNotifyUrl',
value: inputs.WaffoNotifyUrl || '',
});
options.push({
key: 'WaffoReturnUrl',
value: inputs.WaffoReturnUrl || '',
});
//
options.push({
@@ -205,8 +235,6 @@ export default function SettingsPaymentGatewayWaffo(props) {
});
} else {
showSuccess(t('更新成功'));
//
setOriginInputs({ ...inputs });
props.refresh?.();
}
} catch (error) {
@@ -218,7 +246,12 @@ export default function SettingsPaymentGatewayWaffo(props) {
//
const openAddPayMethodModal = () => {
setEditingPayMethodIndex(-1);
setPayMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' });
setPayMethodForm({
name: '',
icon: '',
payMethodType: '',
payMethodName: '',
});
setPayMethodModalVisible(true);
};
@@ -324,19 +357,32 @@ export default function SettingsPaymentGatewayWaffo(props) {
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Waffo 设置')}>
<Text>
{t('Waffo 是一个支付聚合平台,支持多种支付方式。')}
<a href='https://waffo.com' target='_blank' rel='noreferrer'>
Waffo Official Site
</a>
<br />
</Text>
<Form.Section text={sectionTitle}>
<Banner
type='info'
description={t(
'请在 Waffo 后台获取 API 密钥、商户 ID 以及 RSA 密钥对,并配置回调地址。',
)}
icon={<BookOpen size={16} />}
description={
<>
Waffo 密钥商户和支付方式等设置请
<a href='https://waffo.com' target='_blank' rel='noreferrer'>
点击此处
</a>
进行配置切换沙盒模式时请同步填写对应环境的密钥
<br />
{t('回调地址')}
{props.options.ServerAddress
? removeTrailingSlash(props.options.ServerAddress)
: t('网站地址')}
/api/waffo/webhook
</>
}
style={{ marginBottom: 12 }}
/>
<Banner
type='warning'
icon={<TriangleAlert size={16} />}
description={t('请确认商户和所选环境密钥一致。')}
style={{ marginBottom: 16 }}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
@@ -356,161 +402,188 @@ export default function SettingsPaymentGatewayWaffo(props) {
size='default'
checkedText=''
uncheckedText=''
extraText={t('启用后将使用 Waffo 沙盒环境')}
extraText={t('用于切换当前下单和回调校验所使用的环境')}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoApiKey'
label={t('API 密钥 (生产)')}
placeholder={t('生产环境 Waffo API 密钥')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoSandboxApiKey'
label={t('API 密钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo API 密钥')}
type='password'
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoMerchantId'
label={t('商户 ID')}
placeholder={t('Waffo 商户 ID')}
placeholder={t('例如:MER_xxx')}
extraText={t('当前环境共用同一商户 ID')}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoApiKey'
label={t('API 密钥(生产环境)')}
placeholder={t(
'填写后覆盖当前生产环境 API 密钥,留空表示保持当前不变',
)}
extraText={t('保存后不会回显,请填写生产环境对应的 API 密钥')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.TextArea
field='WaffoPrivateKey'
label={t('RSA 私钥 (生产)')}
placeholder={t('生产环境 RSA 私钥 Base64 (PKCS#8 DER)')}
label={t('API 私钥(生产环境)')}
placeholder={t(
'填写后覆盖当前生产环境私钥,留空表示保持当前不变',
)}
extraText={t('保存后不会回显,请填写生产环境对应的 API 私钥')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPrivateKey'
label={t('RSA 私钥 (沙盒)')}
placeholder={t('沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.TextArea
field='WaffoPublicCert'
label={t('Waffo 公钥 (生产)')}
placeholder={t('生产环境 Waffo 公钥 Base64 (X.509 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPublicCert'
label={t('Waffo 公钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo 公钥 Base64 (X.509 DER)')}
label={t('Waffo 公钥(生产环境)')}
placeholder={t(
'填写生产环境 Waffo 公钥,Base64 或 PEM 内容均可',
)}
extraText={t('用于校验生产环境的 Waffo 回调签名')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
</Row>
<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 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoSandboxApiKey'
label={t('API 密钥(测试环境)')}
placeholder={t(
'填写后覆盖当前测试环境 API 密钥,留空表示保持当前不变',
)}
extraText={t('保存后不会回显,请填写测试环境对应的 API 密钥')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.TextArea
field='WaffoSandboxPrivateKey'
label={t('API 私钥(测试环境)')}
placeholder={t(
'填写后覆盖当前测试环境私钥,留空表示保持当前不变',
)}
extraText={t('保存后不会回显,请填写测试环境对应的 API 私钥')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.TextArea
field='WaffoSandboxPublicCert'
label={t('Waffo 公钥(测试环境)')}
placeholder={t(
'填写测试环境 Waffo 公钥,Base64 或 PEM 内容均可',
)}
extraText={t('用于校验测试环境的 Waffo 回调签名')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</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={8} lg={8} xl={8}>
<Form.Input
field='WaffoCurrency'
label={t('货币')}
placeholder='USD'
extraText={t('Waffo 当前使用 USD 结算')}
disabled
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoUnitPrice'
label={t('单价 (USD)')}
placeholder='1.0'
precision={2}
label={t('充值价格(x元/美金)')}
placeholder={t('例如:7,就是7元/美金')}
extraText={t('按 1 美元对应的站内价格填写')}
min={0}
step={0.1}
extraText={t('每个充值单位对应的 USD 金额,默认 1.0')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoMinTopUp'
label={t('最低充值数量')}
placeholder='1'
label={t('最低充值美元数量')}
placeholder={t('例如:2,就是最低充值2$')}
extraText={t('用户单次最少可充值的美元数量')}
min={1}
step={1}
extraText={t('Waffo 充值的最低数量,默认 1')}
/>
</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.Input
field='WaffoNotifyUrl'
label={t('回调通知地址')}
placeholder={t('例如 https://example.com/api/waffo/webhook')}
extraText={t('留空则自动使用 服务器地址 + /api/waffo/webhook')}
label={t('回调地址')}
placeholder={t('例如https://example.com/api/waffo/webhook')}
extraText={t('留空则自动使用当前站点的默认回调地址')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoReturnUrl'
label={t('支付返回地址')}
placeholder={t('例如 https://example.com/console/topup')}
extraText={t('支付完成后用户跳转的页面,留空则自动使用 服务器地址 + /console/topup')}
placeholder={t('例如https://example.com/console/topup')}
extraText={t('留空则自动使用当前站点的默认充值页地址')}
/>
</Col>
</Row>
</Form.Section>
<Form.Section text={t('支付方式设置')}>
<Text type='secondary'>
{t(
'这里配置 Waffo 下展示给用户的 Card、Apple Pay、Google Pay 等子支付方式。',
)}
</Text>
<div style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={openAddPayMethodModal}>{t('新增支付方式')}</Button>
</div>
<Table
columns={payMethodColumns}
dataSource={waffoPayMethods}
rowKey={(record, index) => index}
pagination={false}
size='small'
empty={
<Text type='tertiary'>{t('暂无支付方式,点击上方按钮新增')}</Text>
}
/>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</Form.Section>
</Form>
{/* 支付方式配置区块(独立于 Form,使用独立状态管理) */}
<div style={{ marginTop: 24 }}>
<Typography.Title heading={6} style={{ marginBottom: 8 }}>{t('支付方式')}</Typography.Title>
<Text type='secondary'>
{t('配置 Waffo 充值时可用的支付方式,保存后在充值页面展示给用户。')}
</Text>
<div style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={openAddPayMethodModal}>
{t('新增支付方式')}
</Button>
</div>
<Table
columns={payMethodColumns}
dataSource={waffoPayMethods}
rowKey={(record, index) => index}
pagination={false}
size='small'
empty={<Text type='tertiary'>{t('暂无支付方式,点击上方按钮新增')}</Text>}
/>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</div>
{/* 新增/编辑支付方式弹窗 */}
<Modal
title={editingPayMethodIndex === -1 ? t('新增支付方式') : t('编辑支付方式')}
title={
editingPayMethodIndex === -1 ? t('新增支付方式') : t('编辑支付方式')
}
visible={payMethodModalVisible}
onOk={handlePayMethodModalOk}
onCancel={() => setPayMethodModalVisible(false)}
@@ -521,14 +594,22 @@ export default function SettingsPaymentGatewayWaffo(props) {
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('显示名称')}</Text>
<span style={{ color: 'var(--semi-color-danger)', marginLeft: 4 }}>*</span>
<span
style={{ color: 'var(--semi-color-danger)', marginLeft: 4 }}
>
*
</span>
</div>
<Input
value={payMethodForm.name}
onChange={(val) => setPayMethodForm({ ...payMethodForm, name: val })}
onChange={(val) =>
setPayMethodForm({ ...payMethodForm, name: val })
}
placeholder={t('例如:Credit Card')}
/>
<Text type='tertiary' size='small'>{t('用户在充值页面看到的支付方式名称,例如:Credit Card')}</Text>
<Text type='tertiary' size='small'>
{t('用户在充值页面看到的支付方式名称,例如:Credit Card')}
</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
@@ -574,32 +655,44 @@ export default function SettingsPaymentGatewayWaffo(props) {
)}
</Space>
<div>
<Text type='tertiary' size='small'>{t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')}</Text>
<Text type='tertiary' size='small'>
{t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')}
</Text>
</div>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Type')}</Text>
<Text strong>{t('支付方式类型')}</Text>
</div>
<Input
value={payMethodForm.payMethodType}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodType: val })}
onChange={(val) =>
setPayMethodForm({ ...payMethodForm, payMethodType: val })
}
placeholder='CREDITCARD,DEBITCARD'
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数,可空,例如:CREDITCARD,DEBITCARD(最多64位)')}</Text>
<Text type='tertiary' size='small'>
{t(
'Waffo API 参数,可空,例如:CREDITCARD,DEBITCARD(最多64位)',
)}
</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Name')}</Text>
<Text strong>{t('支付方式名称')}</Text>
</div>
<Input
value={payMethodForm.payMethodName}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodName: val })}
onChange={(val) =>
setPayMethodForm({ ...payMethodForm, payMethodName: val })
}
placeholder={t('可空')}
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数,可空(最多64位)')}</Text>
<Text type='tertiary' size='small'>
{t('Waffo API 参数,可空(最多64位)')}
</Text>
</div>
</div>
</Modal>
@@ -0,0 +1,411 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useRef, useState } from 'react';
import { Banner, Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import {
API,
removeTrailingSlash,
showError,
showSuccess,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { BookOpen, TriangleAlert } from 'lucide-react';
const defaultInputs = {
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
};
const toBoolean = (value) => value === true || value === 'true';
export default function SettingsPaymentGatewayWaffoPancake(props) {
const { t } = useTranslation();
const sectionTitle = props.hideSectionTitle
? undefined
: t('Waffo Pancake 设置');
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState(defaultInputs);
const formApiRef = useRef(null);
useEffect(() => {
if (!props.options || !formApiRef.current) return;
const currentInputs = {
WaffoPancakeEnabled: toBoolean(props.options.WaffoPancakeEnabled),
WaffoPancakeSandbox: toBoolean(props.options.WaffoPancakeSandbox),
WaffoPancakeMerchantID: props.options.WaffoPancakeMerchantID || '',
WaffoPancakePrivateKey: props.options.WaffoPancakePrivateKey || '',
WaffoPancakeWebhookPublicKey:
props.options.WaffoPancakeWebhookPublicKey || '',
WaffoPancakeWebhookTestKey:
props.options.WaffoPancakeWebhookTestKey || '',
WaffoPancakeStoreID: props.options.WaffoPancakeStoreID || '',
WaffoPancakeProductID: props.options.WaffoPancakeProductID || '',
WaffoPancakeReturnURL: props.options.WaffoPancakeReturnURL || '',
WaffoPancakeCurrency: props.options.WaffoPancakeCurrency || 'USD',
WaffoPancakeUnitPrice:
props.options.WaffoPancakeUnitPrice !== undefined
? parseFloat(props.options.WaffoPancakeUnitPrice)
: 1.0,
WaffoPancakeMinTopUp:
props.options.WaffoPancakeMinTopUp !== undefined
? parseFloat(props.options.WaffoPancakeMinTopUp)
: 1,
};
setInputs(currentInputs);
formApiRef.current.setValues(currentInputs);
}, [props.options]);
const handleFormChange = (values) => {
setInputs(values);
};
const submitWaffoPancakeSetting = async () => {
const values = {
...inputs,
...(formApiRef.current?.getValues?.() || {}),
};
values.WaffoPancakeEnabled = toBoolean(values.WaffoPancakeEnabled);
values.WaffoPancakeSandbox = toBoolean(values.WaffoPancakeSandbox);
const currentWebhookField = values.WaffoPancakeSandbox
? 'WaffoPancakeWebhookTestKey'
: 'WaffoPancakeWebhookPublicKey';
const currentWebhookLabel = values.WaffoPancakeSandbox
? t('Webhook 公钥(测试环境)')
: t('Webhook 公钥(生产环境)');
if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) {
showError(t('请输入商户 ID'));
return;
}
if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) {
showError(t('请输入 Store ID'));
return;
}
if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) {
showError(t('请输入 Product ID'));
return;
}
if (
values.WaffoPancakeEnabled &&
!String(values[currentWebhookField] || '').trim()
) {
showError(currentWebhookLabel);
return;
}
if (
values.WaffoPancakeEnabled &&
Number(values.WaffoPancakeUnitPrice) <= 0
) {
showError(t('充值价格必须大于 0'));
return;
}
if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) {
showError(t('最低充值美元数量必须大于 0'));
return;
}
setLoading(true);
try {
const options = [
{
key: 'WaffoPancakeEnabled',
value: values.WaffoPancakeEnabled ? 'true' : 'false',
},
{
key: 'WaffoPancakeSandbox',
value: values.WaffoPancakeSandbox ? 'true' : 'false',
},
{
key: 'WaffoPancakeMerchantID',
value: values.WaffoPancakeMerchantID || '',
},
{
key: 'WaffoPancakeStoreID',
value: values.WaffoPancakeStoreID || '',
},
{
key: 'WaffoPancakeProductID',
value: values.WaffoPancakeProductID || '',
},
{
key: 'WaffoPancakeReturnURL',
value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''),
},
{
key: 'WaffoPancakeCurrency',
value: values.WaffoPancakeCurrency || 'USD',
},
{
key: 'WaffoPancakeUnitPrice',
value: String(values.WaffoPancakeUnitPrice),
},
{
key: 'WaffoPancakeMinTopUp',
value: String(values.WaffoPancakeMinTopUp),
},
];
if ((values.WaffoPancakePrivateKey || '').trim()) {
options.push({
key: 'WaffoPancakePrivateKey',
value: values.WaffoPancakePrivateKey,
});
}
if ((values.WaffoPancakeWebhookPublicKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookPublicKey',
value: values.WaffoPancakeWebhookPublicKey,
});
}
if ((values.WaffoPancakeWebhookTestKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookTestKey',
value: values.WaffoPancakeWebhookTestKey,
});
}
const results = await Promise.all(
options.map((opt) =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
}),
),
);
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach((res) => showError(res.data.message));
return;
}
showSuccess(t('更新成功'));
props.refresh?.();
} catch (error) {
showError(t('更新失败'));
} finally {
setLoading(false);
}
};
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={sectionTitle}>
<Banner
type='info'
icon={<BookOpen size={16} />}
description={
<>
Waffo Pancake 的商户商品和签名密钥请
<a
href='https://docs.waffo.ai'
target='_blank'
rel='noreferrer'
>
点击此处
</a>
获取建议先在测试环境完成联调
<br />
{t('回调地址')}
{props.options.ServerAddress
? removeTrailingSlash(props.options.ServerAddress)
: t('网站地址')}
/api/waffo-pancake/webhook
</>
}
style={{ marginBottom: 12 }}
/>
<Banner
type='warning'
icon={<TriangleAlert size={16} />}
description={t(
'请确认 Merchant、Store、Product 和所选环境密钥一致。',
)}
style={{ marginBottom: 16 }}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoPancakeEnabled'
label={t('启用 Waffo Pancake')}
checkedText=''
uncheckedText=''
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoPancakeSandbox'
label={t('沙盒模式')}
checkedText=''
uncheckedText=''
extraText={t('用于切换当前下单和回调校验所使用的环境')}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeCurrency'
label={t('货币')}
placeholder='USD'
extraText={t('默认使用 USD 结算')}
/>
</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={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeMerchantID'
label={t('商户 ID')}
placeholder={t('例如:MER_xxx')}
extraText={t('请填写当前环境对应的商户 ID')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeStoreID'
label={t('Store ID')}
placeholder={t('例如:STO_xxx')}
extraText={t('请填写当前环境对应的 Store ID')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoPancakeProductID'
label={t('Product ID')}
placeholder={t('例如:PROD_xxx')}
extraText={t('请填写当前环境对应的 Product ID')}
/>
</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='WaffoPancakePrivateKey'
label={t('API 私钥')}
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
extraText={t('保存后不会回显,请填写当前环境对应的 API 私钥')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoPancakeReturnURL'
label={t('支付返回地址')}
placeholder={t('例如:https://example.com/console/topup')}
extraText={t('留空则自动使用当前站点的默认充值页地址')}
/>
</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='WaffoPancakeWebhookPublicKey'
label={t('Webhook 公钥(生产环境)')}
placeholder={t(
'填写后覆盖当前生产环境 Webhook 公钥,留空表示保持当前不变',
)}
extraText={t('用于校验生产环境的 Waffo Pancake Webhook 签名')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoPancakeWebhookTestKey'
label={t('Webhook 公钥(测试环境)')}
placeholder={t(
'填写后覆盖当前测试环境 Webhook 公钥,留空表示保持当前不变',
)}
extraText={t('用于校验测试环境的 Waffo Pancake Webhook 签名')}
type='password'
autosize={{ minRows: 4, maxRows: 8 }}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoPancakeUnitPrice'
precision={2}
label={t('充值价格(x元/美金)')}
placeholder={t('例如:7,就是7元/美金')}
extraText={t('按 1 美元对应的站内价格填写')}
min={0}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoPancakeMinTopUp'
label={t('最低充值美元数量')}
placeholder={t('例如:2,就是最低充值2$')}
extraText={t('用户单次最少可充值的美元数量')}
min={1}
/>
</Col>
</Row>
<Button onClick={submitWaffoPancakeSetting}>
{t('更新 Waffo Pancake 设置')}
</Button>
</Form.Section>
</Form>
</Spin>
);
}