Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d83b5472a | |||
| e729b22197 | |||
| 5f67d2a28b | |||
| d586a567e4 | |||
| 6afaa58d28 | |||
| f995a868e4 | |||
| 5b9dcf1bda | |||
| d75a046791 | |||
| 209645e26b | |||
| 6ff8c7ab03 | |||
| c31343ac76 | |||
| 45cc95a25c |
@@ -116,6 +116,10 @@ var RetryTimes = 0
|
||||
|
||||
var IsMasterNode bool
|
||||
|
||||
// NodeName 节点名称,从 NODE_NAME 环境变量读取;
|
||||
// 用于审计日志中标识节点身份,在容器/K8s 部署时比自动探测到的容器内网 IP 更具可读性。
|
||||
var NodeName = ""
|
||||
|
||||
var requestInterval int
|
||||
var RequestInterval time.Duration
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ func InitEnv() {
|
||||
DebugEnabled = os.Getenv("DEBUG") == "true"
|
||||
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
|
||||
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
|
||||
NodeName = os.Getenv("NODE_NAME")
|
||||
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
|
||||
if TLSInsecureSkipVerify {
|
||||
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
|
||||
|
||||
@@ -460,7 +460,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
|
||||
}
|
||||
}
|
||||
if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
|
||||
if bodyErr := validateTestResponseBody(respBody, isStream); bodyErr != nil {
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: bodyErr,
|
||||
@@ -570,6 +570,42 @@ func detectErrorFromTestResponseBody(respBody []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStreamTestResponseBody(respBody []byte) error {
|
||||
b := bytes.TrimSpace(respBody)
|
||||
if len(b) == 0 {
|
||||
return errors.New("stream response body is empty")
|
||||
}
|
||||
|
||||
for _, line := range bytes.Split(b, []byte{'\n'}) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) {
|
||||
continue
|
||||
}
|
||||
payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
|
||||
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("stream response body does not contain a valid stream event")
|
||||
}
|
||||
|
||||
func validateTestResponseBody(respBody []byte, isStream bool) error {
|
||||
if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
|
||||
return bodyErr
|
||||
}
|
||||
if isStream {
|
||||
return validateStreamTestResponseBody(respBody)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldUseStreamForAutomaticChannelTest(channel *model.Channel) bool {
|
||||
return channel != nil && channel.Type == constant.ChannelTypeCodex
|
||||
}
|
||||
|
||||
func detectErrorMessageFromJSONBytes(jsonBytes []byte) string {
|
||||
if len(jsonBytes) == 0 {
|
||||
return ""
|
||||
@@ -822,7 +858,7 @@ func testAllChannels(notify bool) error {
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, "", "", false)
|
||||
result := testChannel(channel, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
|
||||
|
||||
+12
-2
@@ -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{
|
||||
|
||||
@@ -36,6 +36,10 @@ func PasskeyRegisterBegin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requirePasskeyRegistrationVerification(c, user.Id) {
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
common.ApiError(c, err)
|
||||
@@ -96,6 +100,10 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requirePasskeyRegistrationVerification(c, user.Id) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -151,6 +159,10 @@ func PasskeyDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requirePasskeyDeleteVerification(c, user.Id) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -474,6 +486,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
|
||||
// Mark passkey as ready; /api/verify will convert this into the final secure verification session.
|
||||
session.Set(PasskeyReadySessionKey, time.Now().Unix())
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
session.Delete(secureVerificationMethodSessionKey)
|
||||
if err := session.Save(); err != nil {
|
||||
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
|
||||
return
|
||||
@@ -504,3 +517,60 @@ func getSessionUser(c *gin.Context) (*model.User, error) {
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool {
|
||||
twoFA, err := model.GetTwoFAByUserId(userID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
if twoFA == nil || !twoFA.IsEnabled {
|
||||
return true
|
||||
}
|
||||
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
|
||||
}
|
||||
|
||||
func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
|
||||
twoFA, err := model.GetTwoFAByUserId(userID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
if twoFA != nil && twoFA.IsEnabled {
|
||||
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
|
||||
}
|
||||
|
||||
_, err = model.GetPasskeyByUserID(userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户尚未绑定 Passkey",
|
||||
})
|
||||
return false
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return requireSecureVerificationMethod(c, secureVerificationMethodPasskey)
|
||||
}
|
||||
|
||||
func requireSecureVerificationMethod(c *gin.Context, method string) bool {
|
||||
session := sessions.Default(c)
|
||||
verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64)
|
||||
if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout {
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
session.Delete(secureVerificationMethodSessionKey)
|
||||
_ = session.Save()
|
||||
common.ApiErrorMsg(c, "请先完成安全验证")
|
||||
return false
|
||||
}
|
||||
|
||||
if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method {
|
||||
common.ApiErrorMsg(c, "请先完成对应的安全验证")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
|
||||
const (
|
||||
// SecureVerificationSessionKey means the user has fully passed secure verification.
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
secureVerificationMethodSessionKey = "secure_verified_method"
|
||||
secureVerificationMethod2FA = "2fa"
|
||||
secureVerificationMethodPasskey = "passkey"
|
||||
// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
|
||||
PasskeyReadySessionKey = "secure_passkey_ready_at"
|
||||
// SecureVerificationTimeout 验证有效期(秒)
|
||||
@@ -120,7 +123,7 @@ func UniversalVerify(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 验证成功,在 session 中记录时间戳
|
||||
now, err := setSecureVerificationSession(c)
|
||||
now, err := setSecureVerificationSession(c, req.Method)
|
||||
if err != nil {
|
||||
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
|
||||
return
|
||||
@@ -139,11 +142,12 @@ func UniversalVerify(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func setSecureVerificationSession(c *gin.Context) (int64, error) {
|
||||
func setSecureVerificationSession(c *gin.Context, method string) (int64, error) {
|
||||
session := sessions.Default(c)
|
||||
session.Delete(PasskeyReadySessionKey)
|
||||
now := time.Now().Unix()
|
||||
session.Set(SecureVerificationSessionKey, now)
|
||||
session.Set(secureVerificationMethodSessionKey, method)
|
||||
if err := session.Save(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -542,10 +541,15 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
// 记录操作日志:管理员身份通过 admin_info 传递,避免在非管理员可见的日志内容中泄露。
|
||||
adminId := c.GetInt("id")
|
||||
model.RecordLog(userId, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
|
||||
adminName := c.GetString("username")
|
||||
adminInfo := map[string]interface{}{
|
||||
"admin_id": adminId,
|
||||
"admin_username": adminName,
|
||||
}
|
||||
model.RecordLogWithAdminInfo(userId, model.LogTypeManage,
|
||||
"管理员强制禁用了用户的两步验证", adminInfo)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
|
||||
+11
-6
@@ -918,6 +918,11 @@ func ManageUser(c *gin.Context) {
|
||||
user.Role = common.RoleCommonUser
|
||||
case "add_quota":
|
||||
adminName := c.GetString("username")
|
||||
adminId := c.GetInt("id")
|
||||
adminInfo := map[string]interface{}{
|
||||
"admin_id": adminId,
|
||||
"admin_username": adminName,
|
||||
}
|
||||
switch req.Mode {
|
||||
case "add":
|
||||
if req.Value <= 0 {
|
||||
@@ -928,8 +933,8 @@ func ManageUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)增加用户额度 %s", adminName, logger.LogQuota(req.Value)))
|
||||
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
|
||||
case "subtract":
|
||||
if req.Value <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
@@ -939,16 +944,16 @@ func ManageUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)减少用户额度 %s", adminName, logger.LogQuota(req.Value)))
|
||||
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
|
||||
case "override":
|
||||
oldQuota := user.Quota
|
||||
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)覆盖用户额度从 %s 为 %s", adminName, logger.LogQuota(oldQuota), logger.LogQuota(req.Value)))
|
||||
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value)), adminInfo)
|
||||
default:
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
|
||||
+3
-1
@@ -28,10 +28,11 @@ services:
|
||||
environment:
|
||||
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
|
||||
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
|
||||
- REDIS_CONN_STRING=redis://redis
|
||||
- REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production!
|
||||
- TZ=Asia/Shanghai
|
||||
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
|
||||
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
|
||||
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
|
||||
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
|
||||
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
|
||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||
@@ -55,6 +56,7 @@ services:
|
||||
image: redis:latest
|
||||
container_name: redis
|
||||
restart: always
|
||||
command: ["redis-server", "--requirepass", "123456"] # ⚠️ IMPORTANT: Change this password in production!
|
||||
networks:
|
||||
- new-api-network
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
|
||||
type ToolConfig struct {
|
||||
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
|
||||
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
|
||||
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
|
||||
}
|
||||
|
||||
type FunctionCallingConfig struct {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
|
||||
const (
|
||||
// SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致)
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
secureVerificationMethodSessionKey = "secure_verified_method"
|
||||
// SecureVerificationTimeout 验证有效期(秒)
|
||||
SecureVerificationTimeout = 300 // 5分钟
|
||||
)
|
||||
@@ -48,8 +49,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
|
||||
verifiedAt, ok := verifiedAtRaw.(int64)
|
||||
if !ok {
|
||||
// session 数据格式错误
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "验证状态异常,请重新验证",
|
||||
@@ -63,8 +63,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
|
||||
elapsed := time.Now().Unix() - verifiedAt
|
||||
if elapsed >= SecureVerificationTimeout {
|
||||
// 验证已过期,清除 session
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "验证已过期,请重新验证",
|
||||
@@ -74,11 +73,16 @@ func SecureVerificationRequired() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证有效,继续处理请求
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func clearSecureVerificationSession(session sessions.Session) {
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
session.Delete(secureVerificationMethodSessionKey)
|
||||
_ = session.Save()
|
||||
}
|
||||
|
||||
// OptionalSecureVerification 可选的安全验证中间件
|
||||
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
|
||||
// 用于某些需要区分是否已验证的场景
|
||||
@@ -109,8 +113,7 @@ func OptionalSecureVerification() gin.HandlerFunc {
|
||||
|
||||
elapsed := time.Now().Unix() - verifiedAt
|
||||
if elapsed >= SecureVerificationTimeout {
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
c.Set("secure_verified", false)
|
||||
c.Next()
|
||||
return
|
||||
@@ -126,6 +129,5 @@ func OptionalSecureVerification() gin.HandlerFunc {
|
||||
// 用于用户登出或需要强制重新验证的场景
|
||||
func ClearSecureVerification(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
}
|
||||
|
||||
@@ -90,10 +90,35 @@ func RecordLog(userId int, logType int, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLogWithAdminInfo 记录操作日志,并将管理员相关信息存入 Other.admin_info,
|
||||
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo map[string]interface{}) {
|
||||
if logType == LogTypeConsume && !common.LogConsumeEnabled {
|
||||
return
|
||||
}
|
||||
username, _ := GetUsernameById(userId, false)
|
||||
log := &Log{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
CreatedAt: common.GetTimestamp(),
|
||||
Type: logType,
|
||||
Content: content,
|
||||
}
|
||||
if len(adminInfo) > 0 {
|
||||
other := map[string]interface{}{
|
||||
"admin_info": adminInfo,
|
||||
}
|
||||
log.Other = common.MapToJsonStr(other)
|
||||
}
|
||||
if err := LOG_DB.Create(log).Error; err != nil {
|
||||
common.SysLog("failed to record log: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
|
||||
username, _ := GetUsernameById(userId, false)
|
||||
adminInfo := map[string]interface{}{
|
||||
"server_ip": common.GetIp(),
|
||||
"node_name": common.NodeName,
|
||||
"caller_ip": callerIp,
|
||||
"payment_method": paymentMethod,
|
||||
"callback_payment_method": callbackPaymentMethod,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ var (
|
||||
codexCredentialRefreshRunning atomic.Bool
|
||||
)
|
||||
|
||||
func shouldAutoRefreshCodexChannelStatus(status int) bool {
|
||||
return status == common.ChannelStatusEnabled || status == common.ChannelStatusAutoDisabled
|
||||
}
|
||||
|
||||
func StartCodexCredentialAutoRefreshTask() {
|
||||
codexCredentialRefreshOnce.Do(func() {
|
||||
if !common.IsMasterNode {
|
||||
@@ -65,7 +69,11 @@ func runCodexCredentialAutoRefreshOnce() {
|
||||
var channels []*model.Channel
|
||||
err := model.DB.
|
||||
Select("id", "name", "key", "status", "channel_info").
|
||||
Where("type = ? AND status = 1", constant.ChannelTypeCodex).
|
||||
Where("type = ? AND (status = ? OR status = ?)",
|
||||
constant.ChannelTypeCodex,
|
||||
common.ChannelStatusEnabled,
|
||||
common.ChannelStatusAutoDisabled,
|
||||
).
|
||||
Order("id asc").
|
||||
Limit(codexCredentialRefreshBatchSize).
|
||||
Offset(offset).
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -45,6 +45,8 @@ import EmailBindModal from './personal/modals/EmailBindModal';
|
||||
import WeChatBindModal from './personal/modals/WeChatBindModal';
|
||||
import AccountDeleteModal from './personal/modals/AccountDeleteModal';
|
||||
import ChangePasswordModal from './personal/modals/ChangePasswordModal';
|
||||
import SecureVerificationModal from '../common/modals/SecureVerificationModal';
|
||||
import { useSecureVerification } from '../../hooks/common/useSecureVerification';
|
||||
|
||||
const PersonalSetting = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
@@ -76,6 +78,10 @@ const PersonalSetting = () => {
|
||||
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
|
||||
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [
|
||||
passkeyRequiredVerificationMethod,
|
||||
setPasskeyRequiredVerificationMethod,
|
||||
] = useState(null);
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
warningType: 'email',
|
||||
warningThreshold: 100000,
|
||||
@@ -91,6 +97,34 @@ const PersonalSetting = () => {
|
||||
recordIpLog: false,
|
||||
});
|
||||
|
||||
const {
|
||||
isModalVisible: isPasskeyVerificationModalVisible,
|
||||
verificationMethods: passkeyVerificationMethods,
|
||||
verificationState: passkeyVerificationState,
|
||||
startVerification: startPasskeyVerification,
|
||||
executeVerification: executePasskeyVerification,
|
||||
cancelVerification: cancelPasskeyVerification,
|
||||
setVerificationCode: setPasskeyVerificationCode,
|
||||
switchVerificationMethod: switchPasskeyVerificationMethod,
|
||||
checkVerificationMethods: checkPasskeyVerificationMethods,
|
||||
} = useSecureVerification({
|
||||
onSuccess: () => {
|
||||
setPasskeyRequiredVerificationMethod(null);
|
||||
},
|
||||
});
|
||||
|
||||
const visiblePasskeyVerificationMethods = passkeyRequiredVerificationMethod
|
||||
? {
|
||||
...passkeyVerificationMethods,
|
||||
has2FA:
|
||||
passkeyRequiredVerificationMethod === '2fa' &&
|
||||
passkeyVerificationMethods.has2FA,
|
||||
hasPasskey:
|
||||
passkeyRequiredVerificationMethod === 'passkey' &&
|
||||
passkeyVerificationMethods.hasPasskey,
|
||||
}
|
||||
: passkeyVerificationMethods;
|
||||
|
||||
useEffect(() => {
|
||||
let saved = localStorage.getItem('status');
|
||||
if (saved) {
|
||||
@@ -203,18 +237,57 @@ const PersonalSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterPasskey = async () => {
|
||||
if (!passkeySupported || !window.PublicKeyCredential) {
|
||||
const startPasskeyManagementVerification = async (apiCall, options = {}) => {
|
||||
const methods = await checkPasskeyVerificationMethods();
|
||||
const requiredMethod = methods.has2FA
|
||||
? '2fa'
|
||||
: methods.hasPasskey
|
||||
? 'passkey'
|
||||
: null;
|
||||
|
||||
if (!requiredMethod) {
|
||||
showError(t('您需要先启用两步验证或 Passkey 才能执行此操作'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiredMethod === 'passkey' && !methods.passkeySupported) {
|
||||
showInfo(t('当前设备不支持 Passkey'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyRequiredVerificationMethod(requiredMethod);
|
||||
await startPasskeyVerification(apiCall, {
|
||||
preferredMethod: requiredMethod,
|
||||
title: t('安全验证'),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const startPasskeyRegistration = async () => {
|
||||
const methods = await checkPasskeyVerificationMethods();
|
||||
if (!methods.has2FA) {
|
||||
try {
|
||||
await registerPasskey();
|
||||
} catch (error) {
|
||||
showError(error.message || t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyRequiredVerificationMethod('2fa');
|
||||
await startPasskeyVerification(registerPasskey, {
|
||||
preferredMethod: '2fa',
|
||||
title: t('安全验证'),
|
||||
});
|
||||
};
|
||||
|
||||
const registerPasskey = async () => {
|
||||
setPasskeyRegisterLoading(true);
|
||||
try {
|
||||
const beginRes = await API.post('/api/user/passkey/register/begin');
|
||||
const { success, message, data } = beginRes.data;
|
||||
if (!success) {
|
||||
showError(message || t('无法发起 Passkey 注册'));
|
||||
return;
|
||||
throw new Error(message || t('无法发起 Passkey 注册'));
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialCreationOptions(
|
||||
@@ -223,49 +296,69 @@ const PersonalSetting = () => {
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
const payload = buildRegistrationResult(credential);
|
||||
if (!payload) {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
return;
|
||||
throw new Error(t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
|
||||
const finishRes = await API.post(
|
||||
'/api/user/passkey/register/finish',
|
||||
payload,
|
||||
);
|
||||
if (finishRes.data.success) {
|
||||
showSuccess(t('Passkey 注册成功'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
|
||||
if (!finishRes.data.success) {
|
||||
throw new Error(
|
||||
finishRes.data.message || t('Passkey 注册失败,请重试'),
|
||||
);
|
||||
}
|
||||
|
||||
showSuccess(t('Passkey 注册成功'));
|
||||
await loadPasskeyStatus();
|
||||
return finishRes.data;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
showInfo(t('已取消 Passkey 注册'));
|
||||
} else {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
return { cancelled: true };
|
||||
}
|
||||
throw new Error(error?.message || t('Passkey 注册失败,请重试'));
|
||||
} finally {
|
||||
setPasskeyRegisterLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePasskey = async () => {
|
||||
const handleRegisterPasskey = async () => {
|
||||
if (!passkeySupported || !window.PublicKeyCredential) {
|
||||
showInfo(t('当前设备不支持 Passkey'));
|
||||
return;
|
||||
}
|
||||
await startPasskeyRegistration();
|
||||
};
|
||||
|
||||
const removePasskey = async () => {
|
||||
setPasskeyDeleteLoading(true);
|
||||
try {
|
||||
const res = await API.delete('/api/user/passkey');
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('Passkey 已解绑'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
if (!success) {
|
||||
throw new Error(message || t('操作失败,请重试'));
|
||||
}
|
||||
|
||||
showSuccess(t('Passkey 已解绑'));
|
||||
await loadPasskeyStatus();
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
throw new Error(error?.message || t('操作失败,请重试'));
|
||||
} finally {
|
||||
setPasskeyDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePasskey = async () => {
|
||||
await startPasskeyManagementVerification(removePasskey);
|
||||
};
|
||||
|
||||
const handlePasskeyVerificationCancel = () => {
|
||||
setPasskeyRequiredVerificationMethod(null);
|
||||
cancelPasskeyVerification();
|
||||
};
|
||||
|
||||
const getUserData = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -556,6 +649,18 @@ const PersonalSetting = () => {
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
setTurnstileToken={setTurnstileToken}
|
||||
/>
|
||||
|
||||
<SecureVerificationModal
|
||||
visible={isPasskeyVerificationModalVisible}
|
||||
verificationMethods={visiblePasskeyVerificationMethods}
|
||||
verificationState={passkeyVerificationState}
|
||||
onVerify={executePasskeyVerification}
|
||||
onCancel={handlePasskeyVerificationCancel}
|
||||
onCodeChange={setPasskeyVerificationCode}
|
||||
onMethodSwitch={switchPasskeyVerificationMethod}
|
||||
title={passkeyVerificationState.title}
|
||||
description={passkeyVerificationState.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Collapse,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
import { MOBILE_BREAKPOINT } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -98,10 +99,12 @@ const resolveRateLimitWindows = (data) => {
|
||||
}
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = windows.find((windowData) => windowData !== weeklyWindow) ?? null;
|
||||
fiveHourWindow =
|
||||
windows.find((windowData) => windowData !== weeklyWindow) ?? null;
|
||||
}
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow = windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
|
||||
weeklyWindow =
|
||||
windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow };
|
||||
@@ -135,6 +138,40 @@ const getDisplayText = (value) => {
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
const isMobileViewport = () =>
|
||||
typeof window !== 'undefined' && window.innerWidth < MOBILE_BREAKPOINT;
|
||||
|
||||
const getCodexUsageModalLayout = () => {
|
||||
if (isMobileViewport()) {
|
||||
return {
|
||||
width: 'calc(100vw - 16px)',
|
||||
style: {
|
||||
top: 8,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
margin: '0 auto',
|
||||
},
|
||||
bodyStyle: {
|
||||
maxHeight: 'calc(100vh - 148px)',
|
||||
overflowY: 'auto',
|
||||
padding: '16px 16px 12px',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: 900,
|
||||
style: {
|
||||
top: 24,
|
||||
maxWidth: 'min(900px, 92vw)',
|
||||
},
|
||||
bodyStyle: {
|
||||
maxHeight: 'calc(100vh - 172px)',
|
||||
overflowY: 'auto',
|
||||
padding: '20px 24px 16px',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const formatAccountTypeLabel = (value, t) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const normalized = normalizePlanType(value);
|
||||
@@ -224,7 +261,7 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border border-semi-color-border bg-semi-color-bg-0 p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-x-3 gap-y-1'>
|
||||
<div className='font-medium'>{title}</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{tt('重置时间:')}
|
||||
@@ -262,12 +299,86 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RateLimitWindowGrid = ({ t, fiveHourWindow, weeklyWindow }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
<RateLimitWindowCard
|
||||
t={tt}
|
||||
title={tt('5小时窗口')}
|
||||
windowData={fiveHourWindow}
|
||||
/>
|
||||
<RateLimitWindowCard
|
||||
t={tt}
|
||||
title={tt('每周窗口')}
|
||||
windowData={weeklyWindow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RateLimitGroupSection = ({
|
||||
t,
|
||||
title,
|
||||
description,
|
||||
rateLimitSource,
|
||||
statusTag,
|
||||
meteredFeature,
|
||||
}) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const { fiveHourWindow, weeklyWindow } =
|
||||
resolveRateLimitWindows(rateLimitSource);
|
||||
const featureText = getDisplayText(meteredFeature);
|
||||
|
||||
return (
|
||||
<section className='space-y-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='text-sm font-semibold text-semi-color-text-0'>
|
||||
{title}
|
||||
</div>
|
||||
{statusTag}
|
||||
</div>
|
||||
{(description || featureText) && (
|
||||
<div className='flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
|
||||
{description ? <span>{description}</span> : null}
|
||||
{featureText ? (
|
||||
<div className='inline-flex max-w-full items-center gap-2 rounded-full bg-semi-color-fill-0 px-2 py-1'>
|
||||
<span className='text-[11px] text-semi-color-text-2'>
|
||||
metered_feature
|
||||
</span>
|
||||
<span className='min-w-0 break-all font-mono text-xs text-semi-color-text-0'>
|
||||
{featureText}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RateLimitWindowGrid
|
||||
t={tt}
|
||||
fiveHourWindow={fiveHourWindow}
|
||||
weeklyWindow={weeklyWindow}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
const data = payload?.data ?? null;
|
||||
const rateLimit = data?.rate_limit ?? {};
|
||||
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(data);
|
||||
const additionalRateLimits = Array.isArray(data?.additional_rate_limits)
|
||||
? data.additional_rate_limits.filter(
|
||||
(item) =>
|
||||
item && typeof item === 'object' && Object.keys(item).length > 0,
|
||||
)
|
||||
: [];
|
||||
const upstreamStatus = payload?.upstream_status;
|
||||
const accountType = data?.plan_type ?? rateLimit?.plan_type;
|
||||
const accountTypeLabel = formatAccountTypeLabel(accountType, tt);
|
||||
@@ -277,7 +388,9 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
const email = data?.email;
|
||||
const accountId = data?.account_id;
|
||||
const errorMessage =
|
||||
payload?.success === false ? getDisplayText(payload?.message) || tt('获取用量失败') : '';
|
||||
payload?.success === false
|
||||
? getDisplayText(payload?.message) || tt('获取用量失败')
|
||||
: '';
|
||||
|
||||
const rawText =
|
||||
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
|
||||
@@ -313,7 +426,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Button size='small' type='tertiary' theme='outline' onClick={onRefresh}>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='outline'
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -355,22 +473,61 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
{tt('额度窗口')}
|
||||
</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{tt('用于观察当前帐号在 Codex 上游的限额使用情况')}
|
||||
{tt(
|
||||
'用于观察当前帐号在 Codex 上游的基础限额与附加计费能力使用情况',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
<RateLimitWindowCard
|
||||
<div className='space-y-5'>
|
||||
<RateLimitGroupSection
|
||||
t={tt}
|
||||
title={tt('5小时窗口')}
|
||||
windowData={fiveHourWindow}
|
||||
/>
|
||||
<RateLimitWindowCard
|
||||
t={tt}
|
||||
title={tt('每周窗口')}
|
||||
windowData={weeklyWindow}
|
||||
title={tt('基础额度')}
|
||||
description={tt('当前帐号的基础额度窗口')}
|
||||
rateLimitSource={data}
|
||||
statusTag={statusTag}
|
||||
/>
|
||||
|
||||
{additionalRateLimits.length > 0 ? (
|
||||
<div className='space-y-4 border-t border-semi-color-border pt-4'>
|
||||
<div>
|
||||
<div className='text-sm font-semibold text-semi-color-text-0'>
|
||||
{tt('附加额度')}
|
||||
</div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{tt('按模型或能力拆分的附加计费能力窗口')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{additionalRateLimits.map((item, index) => {
|
||||
const limitName =
|
||||
getDisplayText(item?.limit_name) ||
|
||||
getDisplayText(item?.metered_feature) ||
|
||||
`${tt('附加额度')} ${index + 1}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${limitName}-${getDisplayText(item?.metered_feature)}-${index}`}
|
||||
className={
|
||||
index > 0 ? 'border-t border-semi-color-border pt-4' : ''
|
||||
}
|
||||
>
|
||||
<RateLimitGroupSection
|
||||
t={tt}
|
||||
title={limitName}
|
||||
description={tt('附加计费能力')}
|
||||
rateLimitSource={item}
|
||||
statusTag={resolveUsageStatusTag(tt, item?.rate_limit)}
|
||||
meteredFeature={item?.metered_feature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Collapse
|
||||
@@ -489,12 +646,14 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
|
||||
|
||||
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
|
||||
const tt = typeof t === 'function' ? t : (v) => v;
|
||||
const layout = getCodexUsageModalLayout();
|
||||
|
||||
Modal.info({
|
||||
title: tt('Codex 帐号与用量'),
|
||||
centered: true,
|
||||
width: 900,
|
||||
style: { maxWidth: '95vw' },
|
||||
centered: false,
|
||||
width: layout.width,
|
||||
style: layout.style,
|
||||
bodyStyle: layout.bodyStyle,
|
||||
content: (
|
||||
<CodexUsageLoader
|
||||
t={tt}
|
||||
|
||||
@@ -21,7 +21,6 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Typography,
|
||||
Tag,
|
||||
Card,
|
||||
Button,
|
||||
Banner,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
Col,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Tag,
|
||||
Tabs,
|
||||
TabPane,
|
||||
} from '@douyinfe/semi-ui';
|
||||
@@ -88,8 +88,7 @@ const RechargeCard = ({
|
||||
topupInfo,
|
||||
onOpenHistory,
|
||||
enableWaffoTopUp,
|
||||
waffoTopUp,
|
||||
waffoPayMethods,
|
||||
enableWaffoPancakeTopUp,
|
||||
subscriptionLoading = false,
|
||||
subscriptionPlans = [],
|
||||
billingPreference,
|
||||
@@ -105,6 +104,7 @@ const RechargeCard = ({
|
||||
const [activeTab, setActiveTab] = useState('topup');
|
||||
const shouldShowSubscription =
|
||||
!subscriptionLoading && subscriptionPlans.length > 0;
|
||||
const regularPayMethods = payMethods || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTabSetRef.current) return;
|
||||
@@ -227,19 +227,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 +303,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 +343,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 +393,8 @@ const RechargeCard = ({
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
@@ -388,7 +426,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 +444,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 +495,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 +525,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 充值')}>
|
||||
|
||||
@@ -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'
|
||||
|
||||
+72
-28
@@ -713,36 +713,80 @@ export const useLogsData = () => {
|
||||
value: localCountMode,
|
||||
});
|
||||
}
|
||||
if (isAdminUser && logs[i].type === 1 && other?.admin_info) {
|
||||
if (isAdminUser && logs[i].type === 1) {
|
||||
const adminInfo = other?.admin_info;
|
||||
if (adminInfo) {
|
||||
if (adminInfo.payment_method) {
|
||||
expandDataLocal.push({
|
||||
key: t('订单支付方式'),
|
||||
value: adminInfo.payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.callback_payment_method) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调支付方式'),
|
||||
value: adminInfo.callback_payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.caller_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调调用者IP'),
|
||||
value: adminInfo.caller_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.server_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('服务器IP'),
|
||||
value: adminInfo.server_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.node_name) {
|
||||
expandDataLocal.push({
|
||||
key: t('节点名称'),
|
||||
value: adminInfo.node_name,
|
||||
});
|
||||
}
|
||||
if (adminInfo.version) {
|
||||
expandDataLocal.push({
|
||||
key: t('系统版本'),
|
||||
value: adminInfo.version,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
expandDataLocal.push({
|
||||
key: t('审计信息'),
|
||||
value: (
|
||||
<span style={{ color: 'var(--semi-color-warning)' }}>
|
||||
{t(
|
||||
'该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。',
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isAdminUser && logs[i].type === 3 && other?.admin_info) {
|
||||
const adminInfo = other.admin_info;
|
||||
if (adminInfo.payment_method) {
|
||||
const hasUsername =
|
||||
adminInfo.admin_username !== undefined &&
|
||||
adminInfo.admin_username !== null &&
|
||||
adminInfo.admin_username !== '';
|
||||
const hasId =
|
||||
adminInfo.admin_id !== undefined &&
|
||||
adminInfo.admin_id !== null &&
|
||||
adminInfo.admin_id !== '';
|
||||
if (hasUsername || hasId) {
|
||||
let operatorValue = '';
|
||||
if (hasUsername && hasId) {
|
||||
operatorValue = `${adminInfo.admin_username} (ID: ${adminInfo.admin_id})`;
|
||||
} else if (hasUsername) {
|
||||
operatorValue = String(adminInfo.admin_username);
|
||||
} else {
|
||||
operatorValue = `ID: ${adminInfo.admin_id}`;
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('订单支付方式'),
|
||||
value: adminInfo.payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.callback_payment_method) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调支付方式'),
|
||||
value: adminInfo.callback_payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.caller_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调调用者IP'),
|
||||
value: adminInfo.caller_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.server_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('服务器IP'),
|
||||
value: adminInfo.server_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.version) {
|
||||
expandDataLocal.push({
|
||||
key: t('系统版本'),
|
||||
value: adminInfo.version,
|
||||
key: t('操作管理员'),
|
||||
value: operatorValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+12
-4
@@ -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",
|
||||
@@ -1230,6 +1234,7 @@
|
||||
"实际模型": "Actual model",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Actual charge: {{symbol}}{{total}} (group pricing adjustment included)",
|
||||
"实际请求体": "Actual request body",
|
||||
"审计信息": "Audit Info",
|
||||
"容器": "Container",
|
||||
"容器ID": "Container ID",
|
||||
"容器创建失败: ": "Container creation failed: ",
|
||||
@@ -1695,6 +1700,7 @@
|
||||
"操作成功完成!": "Operation completed successfully!",
|
||||
"操作暂时被禁用": "Operation temporarily disabled",
|
||||
"操作确认": "Operation confirmation",
|
||||
"操作管理员": "Operator Admin",
|
||||
"操作类型": "Operation Type",
|
||||
"操练场": "Playground",
|
||||
"操练场和聊天功能": "Playground and chat functions",
|
||||
@@ -1710,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",
|
||||
@@ -1955,6 +1961,7 @@
|
||||
"服务可用性": "Service Status",
|
||||
"服务商": "Service Provider",
|
||||
"服务器IP": "Server IP",
|
||||
"节点名称": "Node Name",
|
||||
"服务器地址": "Server Address",
|
||||
"服务器日志功能未启用(未配置日志目录)": "Server logging is not enabled (log directory not configured)",
|
||||
"服务器日志管理": "Server Log Management",
|
||||
@@ -2209,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)",
|
||||
@@ -2980,6 +2987,7 @@
|
||||
"该规则未设置参数覆盖模板": "This rule has no parameter override template set",
|
||||
"该规则的缓存保留时长;0 表示使用默认 TTL:": "Cache retention duration for this rule; 0 means using default TTL: ",
|
||||
"该记录不包含可用的 token 统计口径。": "This record does not contain available token statistics.",
|
||||
"该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。": "This record was written by an older instance and lacks audit info. Please upgrade the instance to the latest version so that server IP, callback IP, payment method, and system version audit fields are recorded.",
|
||||
"详情": "Details",
|
||||
"详见「特殊倍率」和「可用分组」标签页。": "See \"Special Ratios\" and \"Usable Groups\" tabs for details.",
|
||||
"语言偏好": "Language Preference",
|
||||
|
||||
Vendored
+12
-4
@@ -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",
|
||||
@@ -1226,6 +1230,7 @@
|
||||
"实际模型": "Modèle réel",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Montant facturé réel : {{symbol}}{{total}} (ajustement tarifaire de groupe inclus)",
|
||||
"实际请求体": "Corps de requête réel",
|
||||
"审计信息": "Informations d'audit",
|
||||
"容器": "Container",
|
||||
"容器ID": "Container ID",
|
||||
"容器创建失败: ": "Container creation failed: ",
|
||||
@@ -1695,6 +1700,7 @@
|
||||
"操作失败,请重试": "L'opération a échoué, veuillez réessayer",
|
||||
"操作成功完成!": "Opération terminée avec succès !",
|
||||
"操作暂时被禁用": "Opération temporairement désactivée",
|
||||
"操作管理员": "Administrateur opérateur",
|
||||
"操作类型": "Type d'opération",
|
||||
"操练场": "Terrain de jeu",
|
||||
"操练场和聊天功能": "Terrain de jeu et fonctions de discussion",
|
||||
@@ -1710,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`.",
|
||||
@@ -1948,6 +1954,7 @@
|
||||
"服务可用性": "État du service",
|
||||
"服务商": "Service Provider",
|
||||
"服务器IP": "IP du serveur",
|
||||
"节点名称": "Nom du nœud",
|
||||
"服务器地址": "Adresse du serveur",
|
||||
"服务器日志功能未启用(未配置日志目录)": "La journalisation du serveur n'est pas activée (répertoire de journaux non configuré)",
|
||||
"服务器日志管理": "Gestion des journaux du serveur",
|
||||
@@ -2200,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",
|
||||
@@ -2966,6 +2973,7 @@
|
||||
"该规则未设置参数覆盖模板": "Cette règle n'a pas de modèle de remplacement de paramètres défini",
|
||||
"该规则的缓存保留时长;0 表示使用默认 TTL:": "Durée de rétention du cache pour cette règle ; 0 signifie utiliser le TTL par défaut : ",
|
||||
"该记录不包含可用的 token 统计口径。": "Cet enregistrement ne contient pas de statistiques de tokens disponibles.",
|
||||
"该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。": "Cet enregistrement a été écrit par une ancienne version de l'instance et ne contient pas d'informations d'audit. Veuillez mettre à jour l'instance vers la dernière version afin d'enregistrer les champs d'audit tels que l'IP du serveur, l'IP de rappel, le mode de paiement et la version du système.",
|
||||
"详情": "Détails",
|
||||
"详见「特殊倍率」和「可用分组」标签页。": "See \"Special Ratios\" and \"Usable Groups\" tabs for details.",
|
||||
"语言偏好": "Préférence linguistique",
|
||||
|
||||
Vendored
+12
-4
@@ -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形式",
|
||||
@@ -1213,6 +1217,7 @@
|
||||
"实际模型": "アップストリームモデル",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "実際の請求額:{{symbol}}{{total}}(グループ価格調整込み)",
|
||||
"实际请求体": "実際のリクエストボディ",
|
||||
"审计信息": "監査情報",
|
||||
"容器": "Container",
|
||||
"容器ID": "Container ID",
|
||||
"容器创建失败: ": "Container creation failed: ",
|
||||
@@ -1666,6 +1671,7 @@
|
||||
"操作失败,请重试": "操作に失敗しました。再試行してください。",
|
||||
"操作成功完成!": "操作が正常に完了しました",
|
||||
"操作暂时被禁用": "この操作は一時的に無効にされています",
|
||||
"操作管理员": "操作管理者",
|
||||
"操作类型": "操作タイプ",
|
||||
"操练场": "Playground",
|
||||
"操练场和聊天功能": "プレイグラウンドとチャット機能",
|
||||
@@ -1681,7 +1687,7 @@
|
||||
"支付渠道": "決済チャネル",
|
||||
"支付设置": "決済",
|
||||
"支付请求失败": "決済リクエストに失敗しました",
|
||||
"支付返回地址": "",
|
||||
"支付返回地址": "返却先 URL",
|
||||
"支付金额": "決済金額",
|
||||
"支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能",
|
||||
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "6桁のTOTP認証コードまたは8桁のバックアップコードに対応しています。`アカウント設定 - セキュリティ設定 - 2要素認証設定`で設定または確認できます。",
|
||||
@@ -1919,6 +1925,7 @@
|
||||
"服务可用性": "サービスの可用性",
|
||||
"服务商": "Service Provider",
|
||||
"服务器IP": "サーバーIP",
|
||||
"节点名称": "ノード名",
|
||||
"服务器地址": "サーバーURL",
|
||||
"服务器日志功能未启用(未配置日志目录)": "サーバーログ機能が有効になっていません(ログディレクトリが未設定)",
|
||||
"服务器日志管理": "サーバーログ管理",
|
||||
@@ -2171,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)",
|
||||
@@ -2935,6 +2942,7 @@
|
||||
"该规则未设置参数覆盖模板": "このルールにはパラメータオーバーライドテンプレートが設定されていません",
|
||||
"该规则的缓存保留时长;0 表示使用默认 TTL:": "このルールのキャッシュ保持期間。0はデフォルトTTLを使用:",
|
||||
"该记录不包含可用的 token 统计口径。": "このレコードには利用可能なトークン統計がありません。",
|
||||
"该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。": "このレコードは古いバージョンのインスタンスによって書き込まれており、監査情報が欠落しています。サーバーIP、コールバックIP、支払い方法、システムバージョンなどの監査フィールドを記録するために、インスタンスを最新バージョンにアップグレードすることをお勧めします。",
|
||||
"详情": "詳細",
|
||||
"详见「特殊倍率」和「可用分组」标签页。": "詳しくは「特殊レート」と「利用可能グループ」タブをご覧ください。",
|
||||
"语言偏好": "言語設定",
|
||||
|
||||
Vendored
+12
-4
@@ -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 обратного вызова",
|
||||
@@ -1234,6 +1238,7 @@
|
||||
"实际模型": "Фактическая модель",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Фактическое списание: {{symbol}}{{total}} (включая групповую ценовую корректировку)",
|
||||
"实际请求体": "Фактическое тело запроса",
|
||||
"审计信息": "Информация об аудите",
|
||||
"容器": "Container",
|
||||
"容器ID": "Container ID",
|
||||
"容器创建失败: ": "Container creation failed: ",
|
||||
@@ -1713,6 +1718,7 @@
|
||||
"操作失败,请重试": "Операция не удалась, попробуйте еще раз",
|
||||
"操作成功完成!": "Операция успешно завершена!",
|
||||
"操作暂时被禁用": "Операция временно отключена",
|
||||
"操作管理员": "Администратор операции",
|
||||
"操作类型": "Тип операции",
|
||||
"操练场": "Тренировочная площадка",
|
||||
"操练场和聊天功能": "Тренировочная площадка и чат-функции",
|
||||
@@ -1728,7 +1734,7 @@
|
||||
"支付渠道": "Платежные каналы",
|
||||
"支付设置": "Оплата",
|
||||
"支付请求失败": "Запрос на оплату не удался",
|
||||
"支付返回地址": "",
|
||||
"支付返回地址": "Адрес возврата",
|
||||
"支付金额": "Сумма оплаты",
|
||||
"支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения",
|
||||
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Поддерживает 6-значные TOTP коды подтверждения или 8-значные резервные коды, можно настроить или просмотреть в `Личные настройки-Настройки безопасности-Настройки двухфакторной аутентификации`.",
|
||||
@@ -1966,6 +1972,7 @@
|
||||
"服务可用性": "Доступность сервиса",
|
||||
"服务商": "Service Provider",
|
||||
"服务器IP": "IP сервера",
|
||||
"节点名称": "Имя узла",
|
||||
"服务器地址": "Адрес сервера",
|
||||
"服务器日志功能未启用(未配置日志目录)": "Ведение журнала сервера не включено (каталог журналов не настроен)",
|
||||
"服务器日志管理": "Управление журналами сервера",
|
||||
@@ -2218,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) песочницы",
|
||||
@@ -2986,6 +2993,7 @@
|
||||
"该规则未设置参数覆盖模板": "У этого правила не задан шаблон переопределения параметров",
|
||||
"该规则的缓存保留时长;0 表示使用默认 TTL:": "Время хранения кэша для этого правила; 0 — использовать TTL по умолчанию: ",
|
||||
"该记录不包含可用的 token 统计口径。": "Эта запись не содержит доступной статистики токенов.",
|
||||
"该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。": "Эта запись была создана более старой версией экземпляра и не содержит данных аудита. Рекомендуется обновить экземпляр до последней версии, чтобы фиксировать поля аудита: IP сервера, IP callback, способ оплаты и версию системы.",
|
||||
"详情": "Подробности",
|
||||
"详见「特殊倍率」和「可用分组」标签页。": "See \"Special Ratios\" and \"Usable Groups\" tabs for details.",
|
||||
"语言偏好": "Языковые настройки",
|
||||
|
||||
Vendored
+12
-4
@@ -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",
|
||||
@@ -1214,6 +1218,7 @@
|
||||
"实际模型": "Mô hình thực tế",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "Khoản phí thực tế: {{symbol}}{{total}} (đã bao gồm điều chỉnh giá theo nhóm)",
|
||||
"实际请求体": "Thân yêu cầu thực tế",
|
||||
"审计信息": "Thông tin kiểm toán",
|
||||
"容器": "Container",
|
||||
"容器ID": "Container ID",
|
||||
"容器创建失败: ": "Container creation failed: ",
|
||||
@@ -1667,6 +1672,7 @@
|
||||
"操作失败,请重试": "Thao tác thất bại, vui lòng thử lại",
|
||||
"操作成功完成!": "Thao tác hoàn tất thành công!",
|
||||
"操作暂时被禁用": "Thao tác tạm thời bị vô hiệu hóa",
|
||||
"操作管理员": "Quản trị viên thao tác",
|
||||
"操作类型": "Loại thao tác",
|
||||
"操练场": "Sân chơi",
|
||||
"操练场和聊天功能": "Chức năng sân chơi và trò chuyện",
|
||||
@@ -1682,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ố`.",
|
||||
@@ -1920,6 +1926,7 @@
|
||||
"服务可用性": "Trạng thái dịch vụ",
|
||||
"服务商": "Service Provider",
|
||||
"服务器IP": "IP máy chủ",
|
||||
"节点名称": "Tên nút",
|
||||
"服务器地址": "Địa chỉ máy chủ",
|
||||
"服务器日志功能未启用(未配置日志目录)": "Ghi nhật ký máy chủ chưa được bật (chưa cấu hình thư mục nhật ký)",
|
||||
"服务器日志管理": "Quản lý nhật ký máy chủ",
|
||||
@@ -2208,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",
|
||||
@@ -3296,6 +3303,7 @@
|
||||
"该规则未设置参数覆盖模板": "Quy tắc này chưa thiết lập mẫu ghi đè tham số",
|
||||
"该规则的缓存保留时长;0 表示使用默认 TTL:": "Thời gian lưu bộ nhớ đệm cho quy tắc này; 0 nghĩa là sử dụng TTL mặc định: ",
|
||||
"该记录不包含可用的 token 统计口径。": "Bản ghi này không chứa thống kê token khả dụng.",
|
||||
"该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。": "Bản ghi này được ghi bởi phiên bản cũ của instance và thiếu thông tin kiểm toán. Khuyến nghị nâng cấp instance lên phiên bản mới nhất để ghi lại các trường kiểm toán như IP máy chủ, IP callback, phương thức thanh toán và phiên bản hệ thống.",
|
||||
"详情": "Chi tiết",
|
||||
"详细信息": "Thông tin chi tiết",
|
||||
"详见「特殊倍率」和「可用分组」标签页。": "See \"Special Ratios\" and \"Usable Groups\" tabs for details.",
|
||||
|
||||
Vendored
+49
-1
@@ -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",
|
||||
"留空则自动使用当前站点的默认回调地址": "留空则自动使用当前站点的默认回调地址",
|
||||
"最后使用时间": "最后使用时间",
|
||||
"最后更新": "最后更新",
|
||||
"最后请求": "最后请求",
|
||||
@@ -1202,6 +1206,7 @@
|
||||
"实际模型": "实际模型",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)",
|
||||
"实际请求体": "实际请求体",
|
||||
"审计信息": "审计信息",
|
||||
"容器": "容器",
|
||||
"容器ID": "容器ID",
|
||||
"容器创建失败: ": "容器创建失败: ",
|
||||
@@ -1655,6 +1660,7 @@
|
||||
"操作成功完成!": "操作成功完成!",
|
||||
"操作暂时被禁用": "操作暂时被禁用",
|
||||
"操作确认": "操作确认",
|
||||
"操作管理员": "操作管理员",
|
||||
"操作类型": "操作类型",
|
||||
"操练场": "操练场",
|
||||
"操练场和聊天功能": "操练场和聊天功能",
|
||||
@@ -1669,6 +1675,7 @@
|
||||
"支付方式类型": "支付方式类型",
|
||||
"支付渠道": "支付渠道",
|
||||
"支付设置": "支付设置",
|
||||
"易支付设置": "易支付设置",
|
||||
"支付请求失败": "支付请求失败",
|
||||
"支付返回地址": "支付返回地址",
|
||||
"支付金额": "支付金额",
|
||||
@@ -1893,6 +1900,7 @@
|
||||
"更新成功": "更新成功",
|
||||
"更新所有已启用通道余额": "更新所有已启用通道余额",
|
||||
"更新支付设置": "更新支付设置",
|
||||
"更新易支付设置": "更新易支付设置",
|
||||
"更新时间": "更新时间",
|
||||
"更新服务器地址": "更新服务器地址",
|
||||
"更新模型信息": "更新模型信息",
|
||||
@@ -1913,6 +1921,7 @@
|
||||
"服务可用性": "服务可用性",
|
||||
"服务商": "服务商",
|
||||
"服务器IP": "服务器IP",
|
||||
"节点名称": "节点名称",
|
||||
"服务器地址": "服务器地址",
|
||||
"服务器日志功能未启用(未配置日志目录)": "服务器日志功能未启用(未配置日志目录)",
|
||||
"服务器日志管理": "服务器日志管理",
|
||||
@@ -2934,6 +2943,7 @@
|
||||
"该规则未设置参数覆盖模板": "该规则未设置参数覆盖模板",
|
||||
"该规则的缓存保留时长;0 表示使用默认 TTL:": "该规则的缓存保留时长;0 表示使用默认 TTL:",
|
||||
"该记录不包含可用的 token 统计口径。": "该记录不包含可用的 token 统计口径。",
|
||||
"该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。": "该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。",
|
||||
"详情": "详情",
|
||||
"详见「特殊倍率」和「可用分组」标签页。": "详见「特殊倍率」和「可用分组」标签页。",
|
||||
"语言偏好": "语言偏好",
|
||||
@@ -3190,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",
|
||||
"账户已删除!": "账户已删除!",
|
||||
"账户已锁定": "账户已锁定",
|
||||
"账户数据": "账户数据",
|
||||
|
||||
Vendored
+12
-4
@@ -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 格式",
|
||||
@@ -1212,6 +1216,7 @@
|
||||
"实际模型": "實際模型",
|
||||
"实际结算金额:{{symbol}}{{total}}(已包含分组价格调整)": "實際結算金額:{{symbol}}{{total}}(已包含分組價格調整)",
|
||||
"实际请求体": "實際請求體",
|
||||
"审计信息": "審計資訊",
|
||||
"容器": "容器",
|
||||
"容器ID": "容器ID",
|
||||
"容器创建失败: ": "容器建立失敗: ",
|
||||
@@ -1666,6 +1671,7 @@
|
||||
"操作成功完成!": "操作成功完成!",
|
||||
"操作暂时被禁用": "操作暫時被禁用",
|
||||
"操作确认": "操作確認",
|
||||
"操作管理员": "操作管理員",
|
||||
"操作类型": "",
|
||||
"操练场": "操練場",
|
||||
"操练场和聊天功能": "操練場和聊天功能",
|
||||
@@ -1681,7 +1687,7 @@
|
||||
"支付渠道": "支付管道",
|
||||
"支付设置": "支付設定",
|
||||
"支付请求失败": "支付請求失敗",
|
||||
"支付返回地址": "",
|
||||
"支付返回地址": "支付返回位址",
|
||||
"支付金额": "支付金額",
|
||||
"支持 Ctrl+V 粘贴图片": "支援 Ctrl+V 貼上圖片",
|
||||
"支持 JSONPath,如 email, data.user.email": "支援 JSONPath,如 email, data.user.email",
|
||||
@@ -1924,6 +1930,7 @@
|
||||
"服务可用性": "服務可用性",
|
||||
"服务商": "服務商",
|
||||
"服务器IP": "伺服器IP",
|
||||
"节点名称": "節點名稱",
|
||||
"服务器地址": "伺服器位址",
|
||||
"服务器日志功能未启用(未配置日志目录)": "伺服器日誌功能未啟用(未配置日誌目錄)",
|
||||
"服务器日志管理": "伺服器日誌管理",
|
||||
@@ -2178,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)",
|
||||
@@ -2946,6 +2953,7 @@
|
||||
"该规则未设置参数覆盖模板": "",
|
||||
"该规则的缓存保留时长;0 表示使用默认 TTL:": "",
|
||||
"该记录不包含可用的 token 统计口径。": "",
|
||||
"该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。": "此記錄由舊版本執行個體寫入,缺少審計資訊,建議將執行個體升級至最新版本,以便記錄伺服器IP、回調IP、支付方式與系統版本等審計欄位。",
|
||||
"详情": "詳情",
|
||||
"详见「特殊倍率」和「可用分组」标签页。": "詳見「特殊倍率」和「可用分組」標籤頁。",
|
||||
"语言偏好": "語言偏好",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user