diff --git a/controller/option.go b/controller/option.go index ecb1e25e..5a7c9f41 100644 --- a/controller/option.go +++ b/controller/option.go @@ -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{ diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go new file mode 100644 index 00000000..9e7a08ae --- /dev/null +++ b/controller/payment_webhook_availability.go @@ -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() +} diff --git a/controller/payment_webhook_availability_test.go b/controller/payment_webhook_availability_test.go new file mode 100644 index 00000000..0534acc4 --- /dev/null +++ b/controller/payment_webhook_availability_test.go @@ -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()) +} diff --git a/controller/subscription_payment_creem.go b/controller/subscription_payment_creem.go index 258d4fb3..935429ac 100644 --- a/controller/subscription_payment_creem.go +++ b/controller/subscription_payment_creem.go @@ -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, diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go index c45b3914..8f7848d5 100644 --- a/controller/subscription_payment_epay.go +++ b/controller/subscription_payment_epay.go @@ -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 } diff --git a/controller/subscription_payment_stripe.go b/controller/subscription_payment_stripe.go index 2603a828..9824c90d 100644 --- a/controller/subscription_payment_stripe.go +++ b/controller/subscription_payment_stripe.go @@ -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, } diff --git a/controller/topup.go b/controller/topup.go index 395156e4..86d361a3 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -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) } - diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 0b6b1627..139dd43f 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -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, 客户邮箱: , 产品: %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 } diff --git a/controller/topup_epay_guard_test.go b/controller/topup_epay_guard_test.go new file mode 100644 index 00000000..34512665 --- /dev/null +++ b/controller/topup_epay_guard_test.go @@ -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) + } + }) + } +} diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index bb8e24fa..23ddb3b9 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -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. diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index f55baefb..c0068062 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -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, "") } diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go new file mode 100644 index 00000000..81515a56 --- /dev/null +++ b/controller/topup_waffo_pancake.go @@ -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") +} diff --git a/controller/topup_waffo_pancake_test.go b/controller/topup_waffo_pancake_test.go new file mode 100644 index 00000000..483dd7b7 --- /dev/null +++ b/controller/topup_waffo_pancake_test.go @@ -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) + }) + } +} diff --git a/dto/values.go b/dto/values.go index 860d5fae..5d748264 100644 --- a/dto/values.go +++ b/dto/values.go @@ -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 { diff --git a/model/option.go b/model/option.go index efa8c01d..37fb6cf5 100644 --- a/model/option.go +++ b/model/option.go @@ -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": diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go new file mode 100644 index 00000000..9bc29244 --- /dev/null +++ b/model/payment_method_guard_test.go @@ -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) +} diff --git a/model/subscription.go b/model/subscription.go index 2d23a8b5..10e750c3 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -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 } diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 3449c6d2..ba34a732 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -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") }) } diff --git a/model/topup.go b/model/topup.go index d1b0c5cb..c1ac663f 100644 --- a/model/topup.go +++ b/model/topup.go @@ -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 diff --git a/router/api-router.go b/router/api-router.go index acc2241b..83f5e4ae 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) + //apiRouter.POST("/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) diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 79c8c49e..39cb8f1d 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -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") }) } diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go new file mode 100644 index 00000000..9033c37f --- /dev/null +++ b/service/waffo_pancake.go @@ -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 +} diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go new file mode 100644 index 00000000..eeb1012b --- /dev/null +++ b/service/waffo_pancake_test.go @@ -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) + }) + } +} diff --git a/setting/payment_waffo_pancake.go b/setting/payment_waffo_pancake.go new file mode 100644 index 00000000..d655059a --- /dev/null +++ b/setting/payment_waffo_pancake.go @@ -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 +) diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 928d58a7..080c3e6e 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -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 = () => { <> - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + {/**/} + {/* */} + {/**/} + diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index f37d129b..17ff77e4 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -21,7 +21,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { Avatar, Typography, - Tag, Card, Button, Banner, @@ -88,8 +87,7 @@ const RechargeCard = ({ topupInfo, onOpenHistory, enableWaffoTopUp, - waffoTopUp, - waffoPayMethods, + enableWaffoPancakeTopUp, subscriptionLoading = false, subscriptionPlans = [], billingPreference, @@ -105,6 +103,7 @@ const RechargeCard = ({ const [activeTab, setActiveTab] = useState('topup'); const shouldShowSubscription = !subscriptionLoading && subscriptionPlans.length > 0; + const regularPayMethods = payMethods || []; useEffect(() => { if (initialTabSetRef.current) return; @@ -227,19 +226,31 @@ const RechargeCard = ({
- ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? ( + ) : enableOnlineTopUp || + enableStripeTopUp || + enableCreemTopUp || + enableWaffoTopUp || + enableWaffoPancakeTopUp ? (
(onlineFormApiRef.current = api)} initValues={{ topUpCount: topUpCount }} >
- {(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && ( + {(enableOnlineTopUp || + enableStripeTopUp || + enableWaffoTopUp || + enableWaffoPancakeTopUp) && ( - {payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && ( - - + {regularPayMethods.length > 0 && ( + + - {payMethods.filter(m => m.type !== 'waffo').map((payMethod) => { - const minTopupVal = Number(payMethod.min_topup) || 0; + {regularPayMethods.map((payMethod) => { + const minTopupVal = + Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; + const isWaffo = + typeof payMethod.type === 'string' && + payMethod.type.startsWith('waffo:'); + const isWaffoPancake = + payMethod.type === 'waffo_pancake'; const disabled = - (!enableOnlineTopUp && !isStripe) || + (!enableOnlineTopUp && + !isStripe && + !isWaffo && + !isWaffoPancake) || (!enableStripeTopUp && isStripe) || + (!enableWaffoTopUp && isWaffo) || + (!enableWaffoPancakeTopUp && isWaffoPancake) || minTopupVal > Number(topUpCount || 0); const buttonEl = ( @@ -320,6 +342,21 @@ const RechargeCard = ({ ) : payMethod.type === 'stripe' ? ( + ) : payMethod.icon ? ( + {payMethod.name} + ) : payMethod.type === 'waffo_pancake' ? ( + ) : ( - - + + )} )} @@ -388,7 +425,9 @@ const RechargeCard = ({
{presetAmounts.map((preset, index) => { const discount = - preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + preset.discount || + topupInfo?.discount?.[preset.value] || + 1.0; const originalPrice = preset.value * priceRatio; const discountedPrice = originalPrice * discount; const hasDiscount = discount < 1.0; @@ -404,7 +443,7 @@ const RechargeCard = ({ const s = JSON.parse(statusStr); usdRate = s?.usd_exchange_rate || 7; } - } catch (e) { } + } catch (e) {} let displayValue = preset.value; // 显示的数量 let displayActualPay = actualPay; @@ -455,7 +494,10 @@ const RechargeCard = ({ {hasDiscount && ( {t('折').includes('off') - ? ((1 - parseFloat(discount)) * 100).toFixed(1) + ? ( + (1 - parseFloat(discount)) * + 100 + ).toFixed(1) : (discount * 10).toFixed(1)} {t('折')} @@ -482,46 +524,6 @@ const RechargeCard = ({ )} - {/* Waffo 充值区域 */} - {enableWaffoTopUp && - waffoPayMethods && - waffoPayMethods.length > 0 && ( - - - {waffoPayMethods.map((method, index) => ( - - ))} - - - )} - {/* Creem 充值区域 */} {enableCreemTopUp && creemProducts.length > 0 && ( diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 0348e3c8..1c23ca92 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -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} diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 8bd5455c..f1c53c12 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -140,6 +140,17 @@ const PaymentConfirmModal = ({ size={16} color='#635BFF' /> + ) : payMethod.icon ? ( + {payMethod.name} ) : ( { 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)} > - + - + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index a4f1029a..d0398a24 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -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)} > - - - {t( - '(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)', + + } + description={t( + '当前仅支持易支付接口,回调地址请在通用设置中配置。', )} - + style={{ marginBottom: 16 }} + /> @@ -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 }} > - - - - + - + - - - - - - - - - - - - - - - - + diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx index 41de8d20..dc1c310e 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx @@ -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)} > - - - {t('Creem 介绍')} - - Creem Official Site - -
-
- + + } + description={ + <> + {t('Creem 介绍')} + + Creem Official Site + +
+ {t('Creem Setting Tips')} + + } + style={{ marginBottom: 16 }} + /> @@ -281,7 +289,7 @@ export default function SettingsPaymentGatewayCreem(props) { diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx index e4ddea11..c8d3da2e 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx @@ -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)} > - - - Stripe 密钥、Webhook 等设置请 - - 点击此处 - - 进行设置,最好先在 - - 测试环境 - - 进行测试。 -
-
+ } + description={ + <> + Stripe 密钥、Webhook 等设置请 + + 点击此处 + + 进行设置,建议先在 + + 测试环境 + + 完成联调。 +
+ {t('回调地址')}: + {props.options.ServerAddress + ? removeTrailingSlash(props.options.ServerAddress) + : t('网站地址')} + /api/stripe/webhook + + } + style={{ marginBottom: 12 }} /> } + description='需要包含事件:checkout.session.completed 和 checkout.session.expired' + style={{ marginBottom: 16 }} /> @@ -209,7 +213,8 @@ export default function SettingsPaymentGateway(props) { @@ -217,7 +222,8 @@ export default function SettingsPaymentGateway(props) { @@ -231,6 +237,7 @@ export default function SettingsPaymentGateway(props) { precision={2} label={t('充值价格(x元/美金)')} placeholder={t('例如:7,就是7元/美金')} + extraText={t('按 1 美元对应的站内价格填写')} /> @@ -238,6 +245,7 @@ export default function SettingsPaymentGateway(props) { field='StripeMinTopUp' label={t('最低充值美元数量')} placeholder={t('例如:2,就是最低充值2$')} + extraText={t('用户单次最少可充值的美元数量')} /> diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx index 29c8cdfa..46802820 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx @@ -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)} > - - - {t('Waffo 是一个支付聚合平台,支持多种支付方式。')} - - Waffo Official Site - -
-
+ } + description={ + <> + Waffo 密钥、商户和支付方式等设置请 + + 点击此处 + + 进行配置,切换沙盒模式时请同步填写对应环境的密钥。 +
+ {t('回调地址')}: + {props.options.ServerAddress + ? removeTrailingSlash(props.options.ServerAddress) + : t('网站地址')} + /api/waffo/webhook + + } + style={{ marginBottom: 12 }} + /> + } + description={t('请确认商户和所选环境密钥一致。')} + style={{ marginBottom: 16 }} /> @@ -356,161 +402,188 @@ export default function SettingsPaymentGatewayWaffo(props) { size='default' checkedText='|' uncheckedText='〇' - extraText={t('启用后将使用 Waffo 沙盒环境')} + extraText={t('用于切换当前下单和回调校验所使用的环境')} /> - - - - - - - - - - - - - + - - + + + + + - - - - - - - + - - - - + + + + + + + + + + + + + + + + +
+ + + {t( + '这里配置 Waffo 下展示给用户的 Card、Apple Pay、Google Pay 等子支付方式。', + )} + +
+ +
+ index} + pagination={false} + size='small' + empty={ + {t('暂无支付方式,点击上方按钮新增')} + } + /> - {/* 支付方式配置区块(独立于 Form,使用独立状态管理) */} -
- {t('支付方式')} - - {t('配置 Waffo 充值时可用的支付方式,保存后在充值页面展示给用户。')} - -
- -
-
index} - pagination={false} - size='small' - empty={{t('暂无支付方式,点击上方按钮新增')}} - /> - - - {/* 新增/编辑支付方式弹窗 */} setPayMethodModalVisible(false)} @@ -521,14 +594,22 @@ export default function SettingsPaymentGatewayWaffo(props) {
{t('显示名称')} - * + + * +
setPayMethodForm({ ...payMethodForm, name: val })} + onChange={(val) => + setPayMethodForm({ ...payMethodForm, name: val }) + } placeholder={t('例如:Credit Card')} /> - {t('用户在充值页面看到的支付方式名称,例如:Credit Card')} + + {t('用户在充值页面看到的支付方式名称,例如:Credit Card')} +
@@ -574,32 +655,44 @@ export default function SettingsPaymentGatewayWaffo(props) { )}
- {t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')} + + {t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')} +
- {t('Pay Method Type')} + {t('支付方式类型')}
setPayMethodForm({ ...payMethodForm, payMethodType: val })} + onChange={(val) => + setPayMethodForm({ ...payMethodForm, payMethodType: val }) + } placeholder='CREDITCARD,DEBITCARD' maxLength={64} /> - {t('Waffo API 参数,可空,例如:CREDITCARD,DEBITCARD(最多64位)')} + + {t( + 'Waffo API 参数,可空,例如:CREDITCARD,DEBITCARD(最多64位)', + )} +
- {t('Pay Method Name')} + {t('支付方式名称')}
setPayMethodForm({ ...payMethodForm, payMethodName: val })} + onChange={(val) => + setPayMethodForm({ ...payMethodForm, payMethodName: val }) + } placeholder={t('可空')} maxLength={64} /> - {t('Waffo API 参数,可空(最多64位)')} + + {t('Waffo API 参数,可空(最多64位)')} +
diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx new file mode 100644 index 00000000..202576df --- /dev/null +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx @@ -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 . + +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 ( + +
(formApiRef.current = api)} + > + + } + description={ + <> + Waffo Pancake 的商户、商品和签名密钥请 + + 点击此处 + + 获取,建议先在测试环境完成联调。 +
+ {t('回调地址')}: + {props.options.ServerAddress + ? removeTrailingSlash(props.options.ServerAddress) + : t('网站地址')} + /api/waffo-pancake/webhook + + } + style={{ marginBottom: 12 }} + /> + } + description={t( + '请确认 Merchant、Store、Product 和所选环境密钥一致。', + )} + style={{ marginBottom: 16 }} + /> + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}