From 30ac0d710a9cbdc678c9b7cc6db2e05d8606e355 Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 6 Apr 2026 16:59:38 +0800 Subject: [PATCH 01/13] feat: integrate waffo pancake pay --- controller/option.go | 14 +- controller/topup.go | 54 ++- controller/topup_waffo.go | 29 ++ controller/topup_waffo_pancake.go | 258 +++++++++++ model/option.go | 36 ++ model/topup.go | 75 +++- router/api-router.go | 4 + service/waffo_pancake.go | 388 +++++++++++++++++ setting/payment_waffo_pancake.go | 16 + .../components/settings/PaymentSetting.jsx | 33 +- web/src/components/topup/RechargeCard.jsx | 116 ++--- web/src/components/topup/index.jsx | 226 ++++++++-- .../topup/modals/PaymentConfirmModal.jsx | 11 + web/src/i18n/locales/zh-CN.json | 37 ++ .../Payment/SettingsPaymentGatewayStripe.jsx | 13 +- .../Payment/SettingsPaymentGatewayWaffo.jsx | 208 +++++---- .../SettingsPaymentGatewayWaffoPancake.jsx | 405 ++++++++++++++++++ 17 files changed, 1712 insertions(+), 211 deletions(-) create mode 100644 controller/topup_waffo_pancake.go create mode 100644 service/waffo_pancake.go create mode 100644 setting/payment_waffo_pancake.go create mode 100644 web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx diff --git a/controller/option.go b/controller/option.go index ecb1e25e8677..5a7c9f418972 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/topup.go b/controller/topup.go index e7a392a4d31d..e5493afe1aed 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -5,6 +5,7 @@ import ( "log" "net/url" "strconv" + "strings" "sync" "time" @@ -78,24 +79,54 @@ func GetTopUpInfo(c *gin.Context) { } } + enableWaffoPancake := setting.WaffoPancakeEnabled && + strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" && + strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" && + ((!setting.WaffoPancakeSandbox && + strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) != "") || + (setting.WaffoPancakeSandbox && + strings.TrimSpace(setting.WaffoPancakeWebhookTestKey) != "")) && + strings.TrimSpace(setting.WaffoPancakeStoreID) != "" && + strings.TrimSpace(setting.WaffoPancakeProductID) != "" + if enableWaffoPancake { + hasWaffoPancake := false + for _, method := range payMethods { + if method["type"] == "waffo_pancake" { + hasWaffoPancake = true + break + } + } + + if !hasWaffoPancake { + payMethods = append(payMethods, map[string]string{ + "name": "Waffo Pancake", + "type": "waffo_pancake", + "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": 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_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) } @@ -463,4 +494,3 @@ func AdminCompleteTopUp(c *gin.Context) { } common.ApiSuccess(c, nil) } - diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index fce376423388..8c937176ba74 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -99,6 +99,35 @@ 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(200, 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)}) + return + } + + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + + payMoney := getWaffoPayMoney(float64(req.Amount), group) + if payMoney <= 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) +} + // RequestWaffoPay 创建 Waffo 支付订单 func RequestWaffoPay(c *gin.Context) { if !setting.WaffoEnabled { diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go new file mode 100644 index 000000000000..22b3529a118f --- /dev/null +++ b/controller/topup_waffo_pancake.go @@ -0,0 +1,258 @@ +package controller + +import ( + "fmt" + "io" + "log" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "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" +) + +const PaymentMethodWaffoPancake = "waffo_pancake" + +type WaffoPancakePayRequest struct { + Amount int64 `json:"amount"` +} + +func RequestWaffoPancakeAmount(c *gin.Context) { + var req WaffoPancakePayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + + if req.Amount < int64(setting.WaffoPancakeMinTopUp) { + c.JSON(200, 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(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + + payMoney := getWaffoPancakePayMoney(req.Amount, group) + if payMoney <= 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + c.JSON(200, 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 waffoPancakeMoneyToMinorUnits(payMoney float64) int64 { + return decimal.NewFromFloat(payMoney). + Mul(decimal.NewFromInt(100)). + Round(0). + IntPart() +} + +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(200, 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(200, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"}) + return + } + + var req WaffoPancakePayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + if req.Amount < int64(setting.WaffoPancakeMinTopUp) { + c.JSON(200, 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(200, gin.H{"message": "error", "data": "用户不存在"}) + return + } + + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + + payMoney := getWaffoPancakePayMoney(req.Amount, group) + if payMoney < 0.01 { + c.JSON(200, 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: PaymentMethodWaffoPancake, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := topUp.Insert(); err != nil { + log.Printf("create Waffo Pancake topup failed: %v", err) + c.JSON(200, 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: fmt.Sprintf("%d", waffoPancakeMoneyToMinorUnits(payMoney)), + TaxIncluded: false, + TaxCategory: "saas", + }, + BuyerEmail: getWaffoPancakeBuyerEmail(user), + SuccessURL: getWaffoPancakeReturnURL(), + ExpiresInSeconds: &expiresInSeconds, + Metadata: map[string]string{ + "internalTradeNo": tradeNo, + "userId": fmt.Sprintf("%d", id), + "topupAmount": fmt.Sprintf("%d", req.Amount), + }, + }) + if err != nil { + log.Printf("create Waffo Pancake checkout session failed: %v", err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + c.JSON(200, 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) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("read Waffo Pancake webhook body failed: %v", err) + c.String(400, "bad request") + return + } + + signature := c.GetHeader("X-Waffo-Signature") + + event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature) + if err != nil { + log.Printf("verify Waffo Pancake webhook failed: %v", err) + c.String(401, "invalid signature") + return + } + + if event.NormalizedEventType() != "order.completed" { + c.String(200, "OK") + return + } + + tradeNo := service.ExtractWaffoPancakeTradeNo(event) + if tradeNo == "" { + log.Printf("Waffo Pancake webhook missing internal trade no, event=%s", event.ID) + c.String(200, "OK") + return + } + + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + + if err := model.RechargeWaffoPancake(tradeNo); err != nil { + log.Printf("Waffo Pancake recharge failed: %v, trade_no=%s", err, tradeNo) + c.String(500, "retry") + return + } + + c.String(200, "OK") +} diff --git a/model/option.go b/model/option.go index 967fa0aa6708..decdddc913ce 100644 --- a/model/option.go +++ b/model/option.go @@ -105,6 +105,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() @@ -404,6 +416,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/topup.go b/model/topup.go index d8c92bfe6517..9d3e7df0cde8 100644 --- a/model/topup.go +++ b/model/topup.go @@ -12,15 +12,15 @@ import ( ) type TopUp struct { - Id int `json:"id"` - UserId int `json:"user_id" gorm:"index"` - Amount int64 `json:"amount"` - Money float64 `json:"money"` - TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` - PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` - CreateTime int64 `json:"create_time"` - CompleteTime int64 `json:"complete_time"` - Status string `json:"status"` + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int64 `json:"amount"` + Money float64 `json:"money"` + TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + Status string `json:"status"` } func (topUp *TopUp) Insert() error { @@ -435,3 +435,60 @@ func RechargeWaffo(tradeNo string) (err error) { 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.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 35d113768be7..e2b2e056ba88 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/waffo_pancake.go b/service/waffo_pancake.go new file mode 100644 index 000000000000..f8f47293fdbb --- /dev/null +++ b/service/waffo_pancake.go @@ -0,0 +1,388 @@ +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/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"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type WaffoPancakeCheckoutSession struct { + SessionID string `json:"sessionId"` + CheckoutURL string `json:"checkoutUrl"` + ExpiresAt string `json:"expiresAt"` +} + +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 int64 `json:"amount"` + TaxAmount int64 `json:"taxAmount"` + ProductName string `json:"productName"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + Metadata map[string]any `json:"metadata"` +} + +type waffoPancakeWebhookEvent struct { + ID string `json:"id"` + Timestamp string `json:"timestamp"` + EventType string `json:"eventType"` + Event string `json:"event"` + 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 "" + } + if e.EventType != "" { + return e.EventType + } + return e.Event +} + +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 == "" { + return nil, fmt.Errorf("Waffo Pancake returned empty checkout session") + } + return result.Data, nil +} + +func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) { + environment := "prod" + if setting.WaffoPancakeSandbox { + environment = "test" + } + return verifyWaffoPancakeWebhook(payload, signatureHeader, environment) +} + +func ExtractWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) string { + return extractWaffoPancakeTradeNo(event) +} + +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 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 +} + +func extractWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) string { + if event == nil { + return "" + } + metadata := event.Data.Metadata + if metadata == nil { + return "" + } + for _, key := range []string{"internalTradeNo", "internalOrderId", "tradeNo", "orderId"} { + if value, ok := metadata[key]; ok { + if tradeNo, ok := value.(string); ok { + return tradeNo + } + } + } + return "" +} diff --git a/setting/payment_waffo_pancake.go b/setting/payment_waffo_pancake.go new file mode 100644 index 000000000000..d655059a9a20 --- /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 928d58a74bb0..55aff83a6ad8 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -24,6 +24,7 @@ import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentG import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe'; import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem'; import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo'; +import 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)); } @@ -147,6 +172,12 @@ const PaymentSetting = () => { + + + ); diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index f37d129b33d3..17ff77e45f7b 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 0348e3c8dd93..1c23ca928c16 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; + 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('支付请求失败')); } - setPaymentLoading(true); - const requestBody = { - amount: parseInt(topUpCount), - }; - if (payMethodIndex != null) { - requestBody.pay_method_index = payMethodIndex; + } else { + showError(res); + } + } catch (e) { + showError(t('支付请求失败')); + } finally { + 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' }); } - 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('支付请求失败')); - } + } 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 { - showError(res); + const errorMsg = + typeof data === 'string' ? data : message || t('支付请求失败'); + showError(errorMsg); } + } else { + showError(res); + } } catch (e) { - showError(t('支付请求失败')); + showError(t('支付请求失败')); } finally { - setPaymentLoading(false); + 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 8bd5455c7f84..f1c53c1219af 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} ) : ( @@ -209,7 +208,8 @@ export default function SettingsPaymentGateway(props) { @@ -217,7 +217,8 @@ export default function SettingsPaymentGateway(props) { @@ -231,6 +232,7 @@ export default function SettingsPaymentGateway(props) { precision={2} label={t('充值价格(x元/美金)')} placeholder={t('例如:7,就是7元/美金')} + extraText={t('按 1 美元对应的站内价格填写')} /> @@ -238,6 +240,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 29c8cdfa1ea4..ccac2ca6f6d8 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx @@ -31,10 +31,16 @@ 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'; const { Text } = Typography; +const toBoolean = (value) => value === true || value === 'true'; export default function SettingsPaymentGatewayWaffo(props) { const { t } = useTranslation(); @@ -55,9 +61,9 @@ export default function SettingsPaymentGatewayWaffo(props) { WaffoNotifyUrl: '', WaffoReturnUrl: '', }); - const [originInputs, setOriginInputs] = useState({}); const formApiRef = useRef(null); const iconFileInputRef = useRef(null); + const isSandboxMode = !!inputs.WaffoSandbox; const handleIconFileChange = (e) => { const file = e.target.files[0]; @@ -93,14 +99,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 +115,6 @@ export default function SettingsPaymentGatewayWaffo(props) { WaffoReturnUrl: props.options.WaffoReturnUrl || '', }; setInputs(currentInputs); - setOriginInputs({ ...currentInputs }); formApiRef.current.setValues(currentInputs); // 解析支付方式列表 @@ -205,8 +210,6 @@ export default function SettingsPaymentGatewayWaffo(props) { }); } else { showSuccess(t('更新成功')); - // 更新本地存储的原始值 - setOriginInputs({ ...inputs }); props.refresh?.(); } } catch (error) { @@ -326,17 +329,27 @@ export default function SettingsPaymentGatewayWaffo(props) { > - {t('Waffo 是一个支付聚合平台,支持多种支付方式。')} - - Waffo Official Site + Waffo 密钥、商户和支付方式等设置请 + + 点击此处 -
+ 进行配置,切换沙盒模式时请同步填写对应环境的密钥。
+ @@ -356,158 +369,169 @@ export default function SettingsPaymentGatewayWaffo(props) { size='default' checkedText='|' uncheckedText='〇' - extraText={t('启用后将使用 Waffo 沙盒环境')} - /> - - - - - - - + - - + + - - - - + - + - - + + + + + - + - + + + + +
+ + + {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('暂无支付方式,点击上方按钮新增')}} - /> - - - {/* 新增/编辑支付方式弹窗 */} . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useRef, useState } from 'react'; +import { + Banner, + Button, + Col, + Form, + Row, + Spin, + Typography, +} from '@douyinfe/semi-ui'; +import { + API, + removeTrailingSlash, + showError, + showSuccess, +} from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +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 [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState(defaultInputs); + const formApiRef = useRef(null); + const isSandboxMode = !!inputs.WaffoPancakeSandbox; + const currentWebhookField = isSandboxMode + ? 'WaffoPancakeWebhookTestKey' + : 'WaffoPancakeWebhookPublicKey'; + const currentWebhookLabel = isSandboxMode + ? t('Webhook 公钥(测试环境)') + : t('Webhook 公钥(生产环境)'); + + 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 () => { + if ( + inputs.WaffoPancakeEnabled && + !(inputs.WaffoPancakePrivateKey || '').trim() + ) { + showError(t('请输入 API 私钥')); + return; + } + + if (inputs.WaffoPancakeEnabled && !inputs.WaffoPancakeMerchantID.trim()) { + showError(t('请输入商户 ID')); + return; + } + + if (inputs.WaffoPancakeEnabled && !inputs.WaffoPancakeStoreID.trim()) { + showError(t('请输入 Store ID')); + return; + } + + if (inputs.WaffoPancakeEnabled && !inputs.WaffoPancakeProductID.trim()) { + showError(t('请输入 Product ID')); + return; + } + + if ( + inputs.WaffoPancakeEnabled && + !String(inputs[currentWebhookField] || '').trim() + ) { + showError(currentWebhookLabel); + return; + } + + if ( + inputs.WaffoPancakeEnabled && + Number(inputs.WaffoPancakeUnitPrice) <= 0 + ) { + showError(t('充值价格必须大于 0')); + return; + } + + if (inputs.WaffoPancakeEnabled && Number(inputs.WaffoPancakeMinTopUp) < 1) { + showError(t('最低充值数量必须大于 0')); + return; + } + + setLoading(true); + try { + const options = [ + { + key: 'WaffoPancakeEnabled', + value: inputs.WaffoPancakeEnabled ? 'true' : 'false', + }, + { + key: 'WaffoPancakeSandbox', + value: inputs.WaffoPancakeSandbox ? 'true' : 'false', + }, + { + key: 'WaffoPancakeMerchantID', + value: inputs.WaffoPancakeMerchantID || '', + }, + { + key: 'WaffoPancakeStoreID', + value: inputs.WaffoPancakeStoreID || '', + }, + { + key: 'WaffoPancakeProductID', + value: inputs.WaffoPancakeProductID || '', + }, + { + key: 'WaffoPancakeReturnURL', + value: removeTrailingSlash(inputs.WaffoPancakeReturnURL || ''), + }, + { + key: 'WaffoPancakeCurrency', + value: inputs.WaffoPancakeCurrency || 'USD', + }, + { + key: 'WaffoPancakeUnitPrice', + value: String(inputs.WaffoPancakeUnitPrice), + }, + { + key: 'WaffoPancakeMinTopUp', + value: String(inputs.WaffoPancakeMinTopUp), + }, + ]; + + if ((inputs.WaffoPancakePrivateKey || '').trim()) { + options.push({ + key: 'WaffoPancakePrivateKey', + value: inputs.WaffoPancakePrivateKey, + }); + } + + if ((inputs.WaffoPancakeWebhookPublicKey || '').trim()) { + options.push({ + key: 'WaffoPancakeWebhookPublicKey', + value: inputs.WaffoPancakeWebhookPublicKey, + }); + } + + if ((inputs.WaffoPancakeWebhookTestKey || '').trim()) { + options.push({ + key: 'WaffoPancakeWebhookTestKey', + value: inputs.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)} + > + + + Waffo Pancake 的商户、商品和签名密钥请 + + 点击此处 + + 获取,建议先在测试环境完成联调。 + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} From efc33fc3b11ac9e4c86d3c9ff1b24396a001488d Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 6 Apr 2026 17:34:45 +0800 Subject: [PATCH 02/13] refactor: simplify epay form and move shared fields to general settings --- .../components/settings/PaymentSetting.jsx | 69 +++++-- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/fr.json | 3 + web/src/i18n/locales/ja.json | 3 + web/src/i18n/locales/ru.json | 3 + web/src/i18n/locales/vi.json | 3 + web/src/i18n/locales/zh-CN.json | 3 + web/src/i18n/locales/zh-TW.json | 3 + .../Payment/SettingsGeneralPayment.jsx | 176 +++++++++++++++-- .../Payment/SettingsPaymentGateway.jsx | 179 ++---------------- .../Payment/SettingsPaymentGatewayCreem.jsx | 28 ++- .../Payment/SettingsPaymentGatewayStripe.jsx | 73 +++---- .../Payment/SettingsPaymentGatewayWaffo.jsx | 158 ++++++++++++---- .../SettingsPaymentGatewayWaffoPancake.jsx | 60 +++--- 14 files changed, 458 insertions(+), 306 deletions(-) diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 55aff83a6ad8..a04a679daaa5 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -18,7 +18,7 @@ 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'; @@ -158,25 +158,54 @@ const PaymentSetting = () => { <> - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index aade2fd39032..edd92dab24e8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1482,6 +1482,7 @@ "支付方式": "Payment method", "支付渠道": "Payment Channels", "支付设置": "Payment Settings", + "易支付设置": "Epay Settings", "支付请求失败": "Payment request failed", "支付金额": "Payment Amount", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", @@ -1687,6 +1688,7 @@ "更新成功": "Updated successfully", "更新所有已启用通道余额": "Update balance for all enabled channels", "更新支付设置": "Update payment settings", + "更新易支付设置": "Update Epay settings", "更新时间": "Update time", "更新服务器地址": "Update Server Address", "更新模型信息": "Update model information", @@ -3267,6 +3269,7 @@ "生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "Production RSA private key Base64 (PKCS#8 DER)", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Sandbox RSA private key Base64 (PKCS#8 DER)", "生产环境 Waffo 公钥 Base64 (X.509 DER)": "Production Waffo public key Base64 (X.509 DER)", + "Waffo 设置": "Waffo Settings", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Sandbox Waffo public key Base64 (X.509 DER)", "支付方式类型": "Pay Method Type", "支付方式名称": "Pay Method Name", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index ec0689fe0bac..b145712bc506 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1474,6 +1474,7 @@ "支付方式": "Mode de paiement", "支付渠道": "Canaux de paiement", "支付设置": "Paiement", + "易支付设置": "Paramètres Epay", "支付请求失败": "Échec de la demande de paiement", "支付金额": "Montant payé", "支持 Ctrl+V 粘贴图片": "Supporte Ctrl+V pour coller l'image", @@ -1674,6 +1675,7 @@ "更新成功": "Mise à jour réussie", "更新所有已启用通道余额": "Mettre à jour le solde de tous les canaux activés", "更新支付设置": "Mettre à jour les paramètres de paiement", + "更新易支付设置": "Mettre à jour les paramètres Epay", "更新时间": "Heure de mise à jour", "更新服务器地址": "Mettre à jour l'adresse du serveur", "更新模型信息": "Mettre à jour les informations du modèle", @@ -3206,6 +3208,7 @@ "生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "Clé privée RSA Base64 (PKCS#8 DER) de production", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Clé privée RSA Base64 (PKCS#8 DER) de sandbox", "生产环境 Waffo 公钥 Base64 (X.509 DER)": "Clé publique Waffo Base64 (X.509 DER) de production", + "Waffo 设置": "Paramètres Waffo", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Clé publique Waffo Base64 (X.509 DER) de sandbox", "支付方式类型": "Type de méthode de paiement", "支付方式名称": "Nom de méthode de paiement", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 73ba69e58091..fb61e4896da3 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -1457,6 +1457,7 @@ "支付方式": "チャージ方法", "支付渠道": "決済チャネル", "支付设置": "決済設定", + "易支付设置": "Epay 設定", "支付请求失败": "決済リクエストに失敗しました", "支付金额": "決済金額", "支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能", @@ -1657,6 +1658,7 @@ "更新成功": "更新に成功しました", "更新所有已启用通道余额": "有効なすべてのチャネルの残高を更新", "更新支付设置": "決済設定の更新", + "更新易支付设置": "Epay 設定を更新", "更新时间": "更新日時", "更新服务器地址": "サーバーURLを更新", "更新模型信息": "モデル情報の更新", @@ -3187,6 +3189,7 @@ "生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "本番環境 RSA 秘密鍵 Base64 (PKCS#8 DER)", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "サンドボックス RSA 秘密鍵 Base64 (PKCS#8 DER)", "生产环境 Waffo 公钥 Base64 (X.509 DER)": "本番環境 Waffo 公開鍵 Base64 (X.509 DER)", + "Waffo 设置": "Waffo 設定", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "サンドボックス Waffo 公開鍵 Base64 (X.509 DER)", "支付方式类型": "決済方法タイプ", "支付方式名称": "決済方法名", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f05905381dc5..d793f0577a15 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1486,6 +1486,7 @@ "支付方式": "Способ оплаты", "支付渠道": "Платежные каналы", "支付设置": "Настройки оплаты", + "易支付设置": "Настройки Epay", "支付请求失败": "Запрос на оплату не удался", "支付金额": "Сумма оплаты", "支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения", @@ -1686,6 +1687,7 @@ "更新成功": "Обновление успешно", "更新所有已启用通道余额": "Обновить баланс всех включенных каналов", "更新支付设置": "Обновить настройки оплаты", + "更新易支付设置": "Обновить настройки Epay", "更新时间": "Время обновления", "更新服务器地址": "Обновить адрес сервера", "更新模型信息": "Обновить информацию о модели", @@ -3220,6 +3222,7 @@ "生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "RSA закрытый ключ Base64 (PKCS#8 DER) производственной среды", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "RSA закрытый ключ Base64 (PKCS#8 DER) песочницы", "生产环境 Waffo 公钥 Base64 (X.509 DER)": "Открытый ключ Waffo Base64 (X.509 DER) производственной среды", + "Waffo 设置": "Настройки Waffo", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Открытый ключ Waffo Base64 (X.509 DER) песочницы", "支付方式类型": "Тип метода оплаты", "支付方式名称": "Название метода оплаты", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 936ebc8d8a94..88e22bcb42a2 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -1458,6 +1458,7 @@ "支付方式": "Phương thức thanh toán", "支付渠道": "Kênh thanh toán", "支付设置": "Cài đặt thanh toán", + "易支付设置": "Cài đặt Epay", "支付请求失败": "Yêu cầu thanh toán thất bại", "支付金额": "Số tiền thanh toán", "支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh", @@ -1658,6 +1659,7 @@ "更新成功": "Cập nhật thành công", "更新所有已启用通道余额": "Cập nhật số dư cho tất cả các kênh đã bật", "更新支付设置": "Cập nhật cài đặt thanh toán", + "更新易支付设置": "Cập nhật cài đặt Epay", "更新时间": "Thời gian cập nhật", "更新服务器地址": "Cập nhật địa chỉ máy chủ", "更新模型信息": "Cập nhật thông tin mô hình", @@ -3757,6 +3759,7 @@ "生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "Khóa riêng RSA Base64 (PKCS#8 DER) môi trường sản xuất", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Khóa riêng RSA Base64 (PKCS#8 DER) môi trường sandbox", "生产环境 Waffo 公钥 Base64 (X.509 DER)": "Khóa công khai Waffo Base64 (X.509 DER) môi trường sản xuất", + "Waffo 设置": "Cài đặt Waffo", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Khóa công khai Waffo Base64 (X.509 DER) môi trường sandbox", "支付方式类型": "Loại phương thức thanh toán", "支付方式名称": "Tên phương thức thanh toán", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index ba79da3d8dd0..7b526472cce7 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -1168,6 +1168,7 @@ "支付宝": "支付宝", "支付方式": "支付方式", "支付设置": "支付设置", + "易支付设置": "易支付设置", "支付请求失败": "支付请求失败", "支付金额": "支付金额", "支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片", @@ -1338,6 +1339,7 @@ "更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。": "更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。", "更新所有已启用通道余额": "更新所有已启用通道余额", "更新支付设置": "更新支付设置", + "更新易支付设置": "更新易支付设置", "更新时间": "更新时间", "更新服务器地址": "更新服务器地址", "更新模型信息": "更新模型信息", @@ -2323,6 +2325,7 @@ "账单": "账单", "账户充值": "账户充值", "Waffo Pancake 设置": "Waffo Pancake 设置", + "Waffo 设置": "Waffo 设置", "Waffo Pancake": "Waffo Pancake", "启用 Waffo Pancake": "启用 Waffo Pancake", "当前入口状态": "当前入口状态", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 7fa777011dcc..2357ea0af202 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -1171,6 +1171,7 @@ "支付宝": "支付寶", "支付方式": "支付方式", "支付设置": "支付設定", + "易支付设置": "易支付設定", "支付请求失败": "支付請求失敗", "支付金额": "支付金額", "支持 Ctrl+V 粘贴图片": "支援 Ctrl+V 貼上圖片", @@ -1344,6 +1345,7 @@ "更新成功": "更新成功", "更新所有已启用通道余额": "更新所有已啟用通道餘額", "更新支付设置": "更新支付設定", + "更新易支付设置": "更新易支付設定", "更新时间": "更新時間", "更新服务器地址": "更新伺服器位址", "更新模型信息": "更新模型資訊", @@ -2946,6 +2948,7 @@ "生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "正式環境 RSA 私鑰 Base64 (PKCS#8 DER)", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "沙盒環境 RSA 私鑰 Base64 (PKCS#8 DER)", "生产环境 Waffo 公钥 Base64 (X.509 DER)": "正式環境 Waffo 公鑰 Base64 (X.509 DER)", + "Waffo 设置": "Waffo 設定", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "沙盒環境 Waffo 公鑰 Base64 (X.509 DER)", "支付方式类型": "付款方式類型", "支付方式名称": "付款方式名稱", diff --git a/web/src/pages/Setting/Payment/SettingsGeneralPayment.jsx b/web/src/pages/Setting/Payment/SettingsGeneralPayment.jsx index 4a9f8c5c7b52..995b1c8cee52 100644 --- a/web/src/pages/Setting/Payment/SettingsGeneralPayment.jsx +++ b/web/src/pages/Setting/Payment/SettingsGeneralPayment.jsx @@ -18,29 +18,43 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { Button, Form, Spin } from '@douyinfe/semi-ui'; +import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { API, removeTrailingSlash, showError, showSuccess, + verifyJSON, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; export default function SettingsGeneralPayment(props) { const { t } = useTranslation(); + const sectionTitle = props.hideSectionTitle ? undefined : t('通用设置'); const [loading, setLoading] = useState(false); const [inputs, setInputs] = useState({ ServerAddress: '', + CustomCallbackAddress: '', + TopupGroupRatio: '', + PayMethods: '', + AmountOptions: '', + AmountDiscount: '', }); + const [originInputs, setOriginInputs] = useState({}); const formApiRef = useRef(null); useEffect(() => { if (props.options && formApiRef.current) { const currentInputs = { ServerAddress: props.options.ServerAddress || '', + CustomCallbackAddress: props.options.CustomCallbackAddress || '', + TopupGroupRatio: props.options.TopupGroupRatio || '', + PayMethods: props.options.PayMethods || '', + AmountOptions: props.options.AmountOptions || '', + AmountDiscount: props.options.AmountDiscount || '', }; setInputs(currentInputs); + setOriginInputs({ ...currentInputs }); formApiRef.current.setValues(currentInputs); } }, [props.options]); @@ -49,19 +63,93 @@ export default function SettingsGeneralPayment(props) { setInputs(values); }; - const submitServerAddress = async () => { + const submitGeneralSettings = async () => { + if ( + originInputs.TopupGroupRatio !== inputs.TopupGroupRatio && + !verifyJSON(inputs.TopupGroupRatio) + ) { + showError(t('充值分组倍率不是合法的 JSON 字符串')); + return; + } + + if ( + originInputs.PayMethods !== inputs.PayMethods && + !verifyJSON(inputs.PayMethods) + ) { + showError(t('充值方式设置不是合法的 JSON 字符串')); + return; + } + + if ( + originInputs.AmountOptions !== inputs.AmountOptions && + inputs.AmountOptions.trim() !== '' && + !verifyJSON(inputs.AmountOptions) + ) { + showError(t('自定义充值数量选项不是合法的 JSON 数组')); + return; + } + + if ( + originInputs.AmountDiscount !== inputs.AmountDiscount && + inputs.AmountDiscount.trim() !== '' && + !verifyJSON(inputs.AmountDiscount) + ) { + showError(t('充值金额折扣配置不是合法的 JSON 对象')); + return; + } + setLoading(true); try { - let ServerAddress = removeTrailingSlash(inputs.ServerAddress); - const res = await API.put('/api/option/', { - key: 'ServerAddress', - value: ServerAddress, - }); - if (res.data.success) { + const options = [ + { + key: 'ServerAddress', + value: removeTrailingSlash(inputs.ServerAddress), + }, + ]; + + if (inputs.CustomCallbackAddress !== '') { + options.push({ + key: 'CustomCallbackAddress', + value: removeTrailingSlash(inputs.CustomCallbackAddress), + }); + } + if (originInputs.TopupGroupRatio !== inputs.TopupGroupRatio) { + options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio }); + } + if (originInputs.PayMethods !== inputs.PayMethods) { + options.push({ key: 'PayMethods', value: inputs.PayMethods }); + } + if (originInputs.AmountOptions !== inputs.AmountOptions) { + options.push({ + key: 'payment_setting.amount_options', + value: inputs.AmountOptions, + }); + } + if (originInputs.AmountDiscount !== inputs.AmountDiscount) { + options.push({ + key: 'payment_setting.amount_discount', + value: inputs.AmountDiscount, + }); + } + + const results = await Promise.all( + options.map((option) => + API.put('/api/option/', { + key: option.key, + value: option.value, + }), + ), + ); + + const errorResults = results.filter((res) => !res.data.success); + if (errorResults.length === 0) { showSuccess(t('更新成功')); + setOriginInputs({ ...inputs }); props.refresh && props.refresh(); } else { - showError(res.data.message); + errorResults.forEach((res) => { + showError(res.data.message); + }); } } catch (error) { showError(t('更新失败')); @@ -76,7 +164,7 @@ export default function SettingsGeneralPayment(props) { onValueChange={handleFormChange} getFormApi={(api) => (formApiRef.current = api)} > - + - + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index a4f1029a1589..76b7e9c38492 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 }} + /> - - - - + - + - - - - - - - - - - - - - - - - + diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx index 41de8d20e904..0c0a8059da98 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 }} + />
diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx index 4dd1463f8fc4..a200007de0e5 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,34 +158,44 @@ export default function SettingsPaymentGateway(props) { onValueChange={handleFormChange} getFormApi={(api) => (formApiRef.current = api)} > - - - Stripe 密钥、Webhook 等设置请 - - 点击此处 - - 进行设置,最好先在 - - 测试环境 - - 进行测试。 -
-
+ } + description={ + <> + Stripe 密钥、Webhook 等设置请 + + 点击此处 + + 进行设置,建议先在 + + 测试环境 + + 完成联调。 +
+ Webhook: + {props.options.ServerAddress + ? removeTrailingSlash(props.options.ServerAddress) + : t('网站地址')} + /api/stripe/webhook + + } + style={{ marginBottom: 12 }} /> } + description='需要包含事件:checkout.session.completed 和 checkout.session.expired' + style={{ marginBottom: 16 }} />
@@ -200,7 +203,9 @@ export default function SettingsPaymentGateway(props) { field='StripeApiSecret' label={t('API 密钥')} placeholder={t('例如:sk_xxx 或 rk_xxx,留空表示保持当前不变')} - extraText={t('保存后不会回显,请填写当前环境对应的 Stripe API 密钥')} + extraText={t( + '保存后不会回显,请填写当前环境对应的 Stripe API 密钥', + )} type='password' /> diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx index ccac2ca6f6d8..afc8fe31ba0c 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx @@ -38,12 +38,14 @@ import { 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, @@ -154,15 +156,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({ @@ -170,7 +187,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({ @@ -183,8 +203,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({ @@ -221,7 +247,12 @@ export default function SettingsPaymentGatewayWaffo(props) { // 打开新增弹窗 const openAddPayMethodModal = () => { setEditingPayMethodIndex(-1); - setPayMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' }); + setPayMethodForm({ + name: '', + icon: '', + payMethodType: '', + payMethodName: '', + }); setPayMethodModalVisible(true); }; @@ -327,29 +358,36 @@ export default function SettingsPaymentGatewayWaffo(props) { onValueChange={handleFormChange} getFormApi={(api) => (formApiRef.current = api)} > - - - Waffo 密钥、商户和支付方式等设置请 - - 点击此处 - - 进行配置,切换沙盒模式时请同步填写对应环境的密钥。 - + } + description={ + <> + Waffo 密钥、商户和支付方式等设置请 + + 点击此处 + + 进行配置,切换沙盒模式时请同步填写对应环境的密钥。 +
+ Webhook: + {props.options.ServerAddress + ? removeTrailingSlash(props.options.ServerAddress) + : t('网站地址')} + /api/waffo/webhook + + } + style={{ marginBottom: 12 }} /> } description={ isSandboxMode ? t('当前显示的是测试环境配置,请确认商户和测试环境密钥一致。') : t('当前显示的是生产环境配置,请确认商户和生产环境密钥一致。') } + style={{ marginBottom: 16 }} /> @@ -390,7 +428,9 @@ export default function SettingsPaymentGatewayWaffo(props) { @@ -399,7 +439,9 @@ export default function SettingsPaymentGatewayWaffo(props) { @@ -434,7 +480,9 @@ export default function SettingsPaymentGatewayWaffo(props) { - {t('这里配置 Waffo 下展示给用户的 Card、Apple Pay、Google Pay 等子支付方式。')} + {t( + '这里配置 Waffo 下展示给用户的 Card、Apple Pay、Google Pay 等子支付方式。', + )}
- +
index} pagination={false} size='small' - empty={{t('暂无支付方式,点击上方按钮新增')}} + empty={ + {t('暂无支付方式,点击上方按钮新增')} + } /> From c161f17c4845e82929cdaad40d6f04b668c1fa74 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 9 Apr 2026 15:32:40 +0800 Subject: [PATCH 03/13] fix: use current sandbox setting when validating Waffo Pancake webhook key --- .../SettingsPaymentGatewayWaffoPancake.jsx | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx index 90f5a5fa0998..6ae67bd4c7c9 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx @@ -53,13 +53,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { const [loading, setLoading] = useState(false); const [inputs, setInputs] = useState(defaultInputs); const formApiRef = useRef(null); - const isSandboxMode = !!inputs.WaffoPancakeSandbox; - const currentWebhookField = isSandboxMode - ? 'WaffoPancakeWebhookTestKey' - : 'WaffoPancakeWebhookPublicKey'; - const currentWebhookLabel = isSandboxMode - ? t('Webhook 公钥(测试环境)') - : t('Webhook 公钥(生产环境)'); useEffect(() => { if (!props.options || !formApiRef.current) return; @@ -96,46 +89,59 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { }; 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 ( - inputs.WaffoPancakeEnabled && - !(inputs.WaffoPancakePrivateKey || '').trim() + values.WaffoPancakeEnabled && + !(values.WaffoPancakePrivateKey || '').trim() ) { showError(t('请输入 API 私钥')); return; } - if (inputs.WaffoPancakeEnabled && !inputs.WaffoPancakeMerchantID.trim()) { + if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) { showError(t('请输入商户 ID')); return; } - if (inputs.WaffoPancakeEnabled && !inputs.WaffoPancakeStoreID.trim()) { + if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) { showError(t('请输入 Store ID')); return; } - if (inputs.WaffoPancakeEnabled && !inputs.WaffoPancakeProductID.trim()) { + if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) { showError(t('请输入 Product ID')); return; } if ( - inputs.WaffoPancakeEnabled && - !String(inputs[currentWebhookField] || '').trim() + values.WaffoPancakeEnabled && + !String(values[currentWebhookField] || '').trim() ) { showError(currentWebhookLabel); return; } if ( - inputs.WaffoPancakeEnabled && - Number(inputs.WaffoPancakeUnitPrice) <= 0 + values.WaffoPancakeEnabled && + Number(values.WaffoPancakeUnitPrice) <= 0 ) { showError(t('充值价格必须大于 0')); return; } - if (inputs.WaffoPancakeEnabled && Number(inputs.WaffoPancakeMinTopUp) < 1) { + if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) { showError(t('最低充值数量必须大于 0')); return; } @@ -145,60 +151,60 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { const options = [ { key: 'WaffoPancakeEnabled', - value: inputs.WaffoPancakeEnabled ? 'true' : 'false', + value: values.WaffoPancakeEnabled ? 'true' : 'false', }, { key: 'WaffoPancakeSandbox', - value: inputs.WaffoPancakeSandbox ? 'true' : 'false', + value: values.WaffoPancakeSandbox ? 'true' : 'false', }, { key: 'WaffoPancakeMerchantID', - value: inputs.WaffoPancakeMerchantID || '', + value: values.WaffoPancakeMerchantID || '', }, { key: 'WaffoPancakeStoreID', - value: inputs.WaffoPancakeStoreID || '', + value: values.WaffoPancakeStoreID || '', }, { key: 'WaffoPancakeProductID', - value: inputs.WaffoPancakeProductID || '', + value: values.WaffoPancakeProductID || '', }, { key: 'WaffoPancakeReturnURL', - value: removeTrailingSlash(inputs.WaffoPancakeReturnURL || ''), + value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''), }, { key: 'WaffoPancakeCurrency', - value: inputs.WaffoPancakeCurrency || 'USD', + value: values.WaffoPancakeCurrency || 'USD', }, { key: 'WaffoPancakeUnitPrice', - value: String(inputs.WaffoPancakeUnitPrice), + value: String(values.WaffoPancakeUnitPrice), }, { key: 'WaffoPancakeMinTopUp', - value: String(inputs.WaffoPancakeMinTopUp), + value: String(values.WaffoPancakeMinTopUp), }, ]; - if ((inputs.WaffoPancakePrivateKey || '').trim()) { + if ((values.WaffoPancakePrivateKey || '').trim()) { options.push({ key: 'WaffoPancakePrivateKey', - value: inputs.WaffoPancakePrivateKey, + value: values.WaffoPancakePrivateKey, }); } - if ((inputs.WaffoPancakeWebhookPublicKey || '').trim()) { + if ((values.WaffoPancakeWebhookPublicKey || '').trim()) { options.push({ key: 'WaffoPancakeWebhookPublicKey', - value: inputs.WaffoPancakeWebhookPublicKey, + value: values.WaffoPancakeWebhookPublicKey, }); } - if ((inputs.WaffoPancakeWebhookTestKey || '').trim()) { + if ((values.WaffoPancakeWebhookTestKey || '').trim()) { options.push({ key: 'WaffoPancakeWebhookTestKey', - value: inputs.WaffoPancakeWebhookTestKey, + value: values.WaffoPancakeWebhookTestKey, }); } From 38d25d24d53d4a69b9e46de35133c3acc29a6588 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 9 Apr 2026 15:38:03 +0800 Subject: [PATCH 04/13] fix: stop requiring Waffo Pancake private key on every save --- .../Payment/SettingsPaymentGatewayWaffoPancake.jsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx index 6ae67bd4c7c9..1ee2b23ab899 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake.jsx @@ -102,15 +102,7 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { ? t('Webhook 公钥(测试环境)') : t('Webhook 公钥(生产环境)'); - if ( - values.WaffoPancakeEnabled && - !(values.WaffoPancakePrivateKey || '').trim() - ) { - showError(t('请输入 API 私钥')); - return; - } - - if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) { + if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) { showError(t('请输入商户 ID')); return; } From 1c8ec519a7412ca2cae06a18b0b941080363673f Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 12 Apr 2026 15:52:45 +0800 Subject: [PATCH 05/13] fix: verify Waffo Pancake webhooks by event mode --- service/waffo_pancake.go | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index f8f47293fdbb..45d47ba1ae9f 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -161,10 +161,7 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake } func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) { - environment := "prod" - if setting.WaffoPancakeSandbox { - environment = "test" - } + environment := resolveWaffoPancakeWebhookEnvironment(payload) return verifyWaffoPancakeWebhook(payload, signatureHeader, environment) } @@ -317,6 +314,33 @@ func parseWaffoPancakeSignatureHeader(header string) (string, string) { return timestampPart, signaturePart } +func resolveWaffoPancakeWebhookEnvironment(payload string) string { + if mode := extractWaffoPancakeWebhookMode(payload); mode != "" { + return mode + } + if setting.WaffoPancakeSandbox { + return "test" + } + return "prod" +} + +func extractWaffoPancakeWebhookMode(payload string) string { + var envelope struct { + Mode string `json:"mode"` + } + if err := common.Unmarshal([]byte(payload), &envelope); err != nil { + return "" + } + switch strings.ToLower(strings.TrimSpace(envelope.Mode)) { + case "test": + return "test" + case "prod": + return "prod" + default: + return "" + } +} + func resolveWaffoPancakeWebhookPublicKey(environment string) string { if environment == "prod" { return strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) From 57637112e6d03732dd98994e22874f6470369737 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 12 Apr 2026 16:22:34 +0800 Subject: [PATCH 06/13] fix: tolerate string amounts in Waffo Pancake webhook payloads --- dto/values.go | 22 ++++++++++++++++++++++ service/waffo_pancake.go | 21 +++++++++++---------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/dto/values.go b/dto/values.go index 860d5fae7ccd..5d7482643a4c 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/service/waffo_pancake.go b/service/waffo_pancake.go index 45d47ba1ae9f..a375c5f64bd1 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -18,6 +18,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/setting" ) @@ -62,16 +63,16 @@ type waffoPancakeCreateSessionResponse struct { } type waffoPancakeWebhookData struct { - ID string `json:"id"` - OrderID string `json:"orderId"` - BuyerEmail string `json:"buyerEmail"` - Currency string `json:"currency"` - Amount int64 `json:"amount"` - TaxAmount int64 `json:"taxAmount"` - ProductName string `json:"productName"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` - Metadata map[string]any `json:"metadata"` + 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"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + Metadata map[string]any `json:"metadata"` } type waffoPancakeWebhookEvent struct { From 7eaaff579c728c9de1d8b919651f07bccd0e67e9 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 12 Apr 2026 16:30:43 +0800 Subject: [PATCH 07/13] fix: handle Waffo Pancake webhook with fixed event fields --- controller/topup_waffo_pancake.go | 11 +--- service/task_billing_test.go | 2 + service/waffo_pancake.go | 91 +++++++++++++++++++++++-------- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 22b3529a118f..ca8e8f265922 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -191,11 +191,6 @@ func RequestWaffoPancakePay(c *gin.Context) { BuyerEmail: getWaffoPancakeBuyerEmail(user), SuccessURL: getWaffoPancakeReturnURL(), ExpiresInSeconds: &expiresInSeconds, - Metadata: map[string]string{ - "internalTradeNo": tradeNo, - "userId": fmt.Sprintf("%d", id), - "topupAmount": fmt.Sprintf("%d", req.Amount), - }, }) if err != nil { log.Printf("create Waffo Pancake checkout session failed: %v", err) @@ -238,9 +233,9 @@ func WaffoPancakeWebhook(c *gin.Context) { return } - tradeNo := service.ExtractWaffoPancakeTradeNo(event) - if tradeNo == "" { - log.Printf("Waffo Pancake webhook missing internal trade no, event=%s", event.ID) + tradeNo, err := service.ResolveWaffoPancakeTradeNo(event) + if err != nil { + log.Printf("Waffo Pancake webhook resolve trade no failed: %v, event=%s, order_id=%s", err, event.ID, event.Data.OrderID) c.String(200, "OK") return } diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 79c8c49eb48d..39cb8f1da1aa 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 index a375c5f64bd1..3fbc5f8672f1 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -19,7 +19,9 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" + "github.com/shopspring/decimal" ) const ( @@ -43,7 +45,6 @@ type WaffoPancakeCreateSessionParams struct { BuyerEmail string `json:"buyerEmail,omitempty"` SuccessURL string `json:"successUrl,omitempty"` ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` } type WaffoPancakeCheckoutSession struct { @@ -70,16 +71,12 @@ type waffoPancakeWebhookData struct { Amount dto.StringValue `json:"amount"` TaxAmount dto.StringValue `json:"taxAmount"` ProductName string `json:"productName"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` - Metadata map[string]any `json:"metadata"` } type waffoPancakeWebhookEvent struct { ID string `json:"id"` Timestamp string `json:"timestamp"` EventType string `json:"eventType"` - Event string `json:"event"` EventID string `json:"eventId"` StoreID string `json:"storeId"` Mode string `json:"mode"` @@ -90,10 +87,7 @@ func (e *waffoPancakeWebhookEvent) NormalizedEventType() string { if e == nil { return "" } - if e.EventType != "" { - return e.EventType - } - return e.Event + return e.EventType } func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancakeCreateSessionParams) (*WaffoPancakeCheckoutSession, error) { @@ -166,8 +160,8 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) return verifyWaffoPancakeWebhook(payload, signatureHeader, environment) } -func ExtractWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) string { - return extractWaffoPancakeTradeNo(event) +func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) { + return resolveWaffoPancakeTradeNoFallback(event) } func normalizeRSAPrivateKey(raw string) (string, error) { @@ -394,20 +388,73 @@ func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart strin return nil } -func extractWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) string { +func resolveWaffoPancakeTradeNoFallback(event *waffoPancakeWebhookEvent) (string, error) { if event == nil { - return "" + return "", fmt.Errorf("missing webhook event") } - metadata := event.Data.Metadata - if metadata == nil { - return "" + + userID, err := resolveWaffoPancakeUserIDByBuyerEmail(event.Data.BuyerEmail) + if err != nil { + return "", err } - for _, key := range []string{"internalTradeNo", "internalOrderId", "tradeNo", "orderId"} { - if value, ok := metadata[key]; ok { - if tradeNo, ok := value.(string); ok { - return tradeNo - } + + amountStr := strings.TrimSpace(string(event.Data.Amount)) + if amountStr == "" { + return "", fmt.Errorf("missing webhook amount") + } + payMoney, err := decimal.NewFromString(amountStr) + if err != nil { + return "", fmt.Errorf("invalid webhook amount %q: %w", amountStr, err) + } + + tolerance := decimal.NewFromFloat(0.000001) + minMoney := payMoney.Sub(tolerance).InexactFloat64() + maxMoney := payMoney.Add(tolerance).InexactFloat64() + + var topUps []*model.TopUp + if err := model.DB. + Where("user_id = ? AND payment_method = ? AND status = ? AND money >= ? AND money <= ?", + userID, "waffo_pancake", common.TopUpStatusPending, minMoney, maxMoney). + Order("create_time desc"). + Limit(2). + Find(&topUps).Error; err != nil { + return "", fmt.Errorf("query pending Waffo Pancake topup failed: %w", err) + } + + if len(topUps) == 0 { + return "", fmt.Errorf("no pending Waffo Pancake topup matched buyerEmail=%s amount=%s", event.Data.BuyerEmail, amountStr) + } + if len(topUps) > 1 { + return "", fmt.Errorf("multiple pending Waffo Pancake topups matched buyerEmail=%s amount=%s", event.Data.BuyerEmail, amountStr) + } + return topUps[0].TradeNo, nil +} + +func resolveWaffoPancakeUserIDByBuyerEmail(email string) (int, error) { + trimmedEmail := strings.TrimSpace(email) + if trimmedEmail == "" { + return 0, fmt.Errorf("missing buyerEmail") + } + + const localSuffix = "@new-api.local" + if strings.HasSuffix(strings.ToLower(trimmedEmail), localSuffix) { + userIDStr := strings.TrimSpace(trimmedEmail[:len(trimmedEmail)-len(localSuffix)]) + userID, err := strconv.Atoi(userIDStr) + if err != nil || userID <= 0 { + return 0, fmt.Errorf("invalid local buyerEmail %q", trimmedEmail) } + return userID, nil + } + + var users []*model.User + if err := model.DB.Select("id").Where("email = ?", trimmedEmail).Limit(2).Find(&users).Error; err != nil { + return 0, fmt.Errorf("query user by buyerEmail failed: %w", err) + } + if len(users) == 0 { + return 0, fmt.Errorf("user not found for buyerEmail=%s", trimmedEmail) + } + if len(users) > 1 { + return 0, fmt.Errorf("multiple users matched buyerEmail=%s", trimmedEmail) } - return "" + return users[0].Id, nil } From b73db121b47608b4fffb4f7e40ccd30fbc100e17 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 18 Apr 2026 11:08:56 +0800 Subject: [PATCH 08/13] fix(payment): enforce payment-method guards and harden Waffo Pancake webhook matching --- controller/subscription_payment_creem.go | 2 +- controller/subscription_payment_epay.go | 6 +- controller/subscription_payment_stripe.go | 2 +- controller/topup.go | 31 +++- controller/topup_creem.go | 11 +- controller/topup_epay_guard_test.go | 31 ++++ controller/topup_stripe.go | 25 +--- controller/topup_waffo.go | 11 +- controller/topup_waffo_pancake.go | 4 +- model/payment_method_guard_test.go | 172 ++++++++++++++++++++++ model/subscription.go | 12 +- model/topup.go | 58 +++++++- service/waffo_pancake.go | 110 +++----------- service/waffo_pancake_test.go | 157 ++++++++++++++++++++ 14 files changed, 490 insertions(+), 142 deletions(-) create mode 100644 controller/topup_epay_guard_test.go create mode 100644 model/payment_method_guard_test.go create mode 100644 service/waffo_pancake_test.go diff --git a/controller/subscription_payment_creem.go b/controller/subscription_payment_creem.go index 258d4fb35718..db70f9b1a100 100644 --- a/controller/subscription_payment_creem.go +++ b/controller/subscription_payment_creem.go @@ -85,7 +85,7 @@ 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, } diff --git a/controller/subscription_payment_epay.go b/controller/subscription_payment_epay.go index c45b39145821..8f7848d5cf65 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 2603a828072b..4db3cb8801d5 100644 --- a/controller/subscription_payment_stripe.go +++ b/controller/subscription_payment_stripe.go @@ -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 93d8c74d402f..1b1c0f75004f 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -62,7 +62,7 @@ func GetTopUpInfo(c *gin.Context) { if enableWaffo { hasWaffo := false for _, method := range payMethods { - if method["type"] == "waffo" { + if method["type"] == model.PaymentMethodWaffo { hasWaffo = true break } @@ -71,7 +71,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), } @@ -91,7 +91,7 @@ func GetTopUpInfo(c *gin.Context) { if enableWaffoPancake { hasWaffoPancake := false for _, method := range payMethods { - if method["type"] == "waffo_pancake" { + if method["type"] == model.PaymentMethodWaffoPancake { hasWaffoPancake = true break } @@ -100,7 +100,7 @@ func GetTopUpInfo(c *gin.Context) { if !hasWaffoPancake { payMethods = append(payMethods, map[string]string{ "name": "Waffo Pancake", - "type": "waffo_pancake", + "type": model.PaymentMethodWaffoPancake, "color": "rgba(var(--semi-orange-5), 1)", "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp), }) @@ -140,6 +140,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 @@ -259,7 +270,7 @@ 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 { @@ -371,12 +382,16 @@ func EpayNotify(c *gin.Context) { log.Printf("易支付回调未找到订单: %v", verifyInfo) return } - if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "creem" || topUp.PaymentMethod == "waffo" { + if isNonEpayPaymentMethodForEpayCallback(topUp.PaymentMethod) { log.Printf("易支付回调订单支付方式不匹配: %s, 订单号: %s", topUp.PaymentMethod, verifyInfo.ServiceTradeNo) return } - if topUp.Status == "pending" { - topUp.Status = "success" + if topUp.PaymentMethod != verifyInfo.Type { + log.Printf("易支付回调订单支付方式不匹配: order=%s callback=%s, 订单号: %s", topUp.PaymentMethod, verifyInfo.Type, verifyInfo.ServiceTradeNo) + return + } + if topUp.Status == common.TopUpStatusPending { + topUp.Status = common.TopUpStatusSuccess err := topUp.Update() if err != nil { log.Printf("易支付回调更新订单失败: %v", topUp) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 0b6b1627e71d..5b1095fecbbd 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -20,10 +20,7 @@ import ( "github.com/thanhpk/randstr" ) -const ( - PaymentMethodCreem = "creem" - CreemSignatureHeader = "creem-signature" -) +const CreemSignatureHeader = "creem-signature" var creemAdaptor = &CreemAdaptor{} @@ -66,7 +63,7 @@ type CreemAdaptor struct { } func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { - if req.PaymentMethod != PaymentMethodCreem { + if req.PaymentMethod != model.PaymentMethodCreem { c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) return } @@ -112,7 +109,7 @@ 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, } @@ -303,7 +300,7 @@ 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 { c.Status(http.StatusOK) return } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { diff --git a/controller/topup_epay_guard_test.go b/controller/topup_epay_guard_test.go new file mode 100644 index 000000000000..345126655837 --- /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 bb8e24fa45b0..4d8ea686b6f5 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -23,10 +23,6 @@ import ( "github.com/thanhpk/randstr" ) -const ( - PaymentMethodStripe = "stripe" -) - var stripeAdaptor = &StripeAdaptor{} // StripePayRequest represents a payment request for Stripe checkout. @@ -66,7 +62,7 @@ func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) { } func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { - if req.PaymentMethod != PaymentMethodStripe { + if req.PaymentMethod != model.PaymentMethodStripe { c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) return } @@ -108,7 +104,7 @@ 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, } @@ -235,7 +231,7 @@ func sessionAsyncPaymentFailed(event stripe.Event, callerIp string) { return } - if topUp.PaymentMethod != PaymentMethodStripe { + if topUp.PaymentMethod != model.PaymentMethodStripe { log.Printf("异步支付失败,订单支付方式不匹配: %s, ref: %s", topUp.PaymentMethod, referenceId) return } @@ -268,7 +264,7 @@ 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 { return } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { log.Println("complete subscription order failed:", err.Error(), referenceId) @@ -302,25 +298,18 @@ func sessionExpired(event stripe.Event) { // Subscription order expiration LockOrder(referenceId) defer UnlockOrder(referenceId) - if err := model.ExpireSubscriptionOrder(referenceId); err == nil { + if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentMethodStripe); err == nil { return } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { log.Println("过期订阅订单失败", referenceId, ", err:", err.Error()) return } - topUp := model.GetTopUpByTradeNo(referenceId) - if topUp == nil { + err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentMethodStripe, common.TopUpStatusExpired) + if errors.Is(err, model.ErrTopUpNotFound) { log.Println("充值订单不存在", 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()) return diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index ae4f717178f4..c937b6c17879 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "fmt" "io" "log" @@ -211,7 +212,7 @@ func RequestWaffoPay(c *gin.Context) { Amount: amount, Money: payMoney, TradeNo: merchantOrderId, - PaymentMethod: "waffo", + PaymentMethod: model.PaymentMethodWaffo, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } @@ -371,10 +372,10 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa log.Printf("Waffo 订单状态非成功: %s, 订单: %s", result.OrderStatus, result.MerchantOrderID) // 终态失败订单标记为 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) { + log.Printf("Waffo 标记失败订单时处理失败: %v, 订单: %s", err, result.MerchantOrderID) } } sendWaffoWebhookResponse(c, wh, true, "") diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index ca8e8f265922..ec536fb476ea 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -18,8 +18,6 @@ import ( "github.com/thanhpk/randstr" ) -const PaymentMethodWaffoPancake = "waffo_pancake" - type WaffoPancakePayRequest struct { Amount int64 `json:"amount"` } @@ -167,7 +165,7 @@ func RequestWaffoPancakePay(c *gin.Context) { Amount: normalizeWaffoPancakeTopUpAmount(req.Amount), Money: payMoney, TradeNo: tradeNo, - PaymentMethod: PaymentMethodWaffoPancake, + PaymentMethod: model.PaymentMethodWaffoPancake, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go new file mode 100644 index 000000000000..9bc292444fe3 --- /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 2d23a8b5bf2c..10e750c3f355 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/topup.go b/model/topup.go index e2b83e408d16..c1ac663f7595 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,7 @@ 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 @@ -497,6 +535,10 @@ func RechargeWaffoPancake(tradeNo string) (err error) { return errors.New("充值订单不存在") } + if topUp.PaymentMethod != PaymentMethodWaffoPancake { + return ErrPaymentMethodMismatch + } + if topUp.Status == common.TopUpStatusSuccess { return nil } diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index 3fbc5f8672f1..9033c37f37d1 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -21,7 +21,6 @@ import ( "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" - "github.com/shopspring/decimal" ) const ( @@ -51,6 +50,7 @@ type WaffoPancakeCheckoutSession struct { SessionID string `json:"sessionId"` CheckoutURL string `json:"checkoutUrl"` ExpiresAt string `json:"expiresAt"` + OrderID string `json:"orderId"` } type waffoPancakeAPIError struct { @@ -149,7 +149,7 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake if len(result.Errors) > 0 { return nil, fmt.Errorf("Waffo Pancake error: %s", result.Errors[0].Message) } - if result.Data == nil || result.Data.CheckoutURL == "" { + 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 @@ -161,7 +161,19 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) } func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) { - return resolveWaffoPancakeTradeNoFallback(event) + 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) { @@ -310,29 +322,26 @@ func parseWaffoPancakeSignatureHeader(header string) (string, string) { } func resolveWaffoPancakeWebhookEnvironment(payload string) string { - if mode := extractWaffoPancakeWebhookMode(payload); mode != "" { - return mode - } - if setting.WaffoPancakeSandbox { - return "test" - } - return "prod" -} - -func extractWaffoPancakeWebhookMode(payload string) string { var envelope struct { Mode string `json:"mode"` } if err := common.Unmarshal([]byte(payload), &envelope); err != nil { - return "" + if setting.WaffoPancakeSandbox { + return "test" + } + return "prod" } + switch strings.ToLower(strings.TrimSpace(envelope.Mode)) { case "test": return "test" case "prod": return "prod" default: - return "" + if setting.WaffoPancakeSandbox { + return "test" + } + return "prod" } } @@ -387,74 +396,3 @@ func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart strin } return nil } - -func resolveWaffoPancakeTradeNoFallback(event *waffoPancakeWebhookEvent) (string, error) { - if event == nil { - return "", fmt.Errorf("missing webhook event") - } - - userID, err := resolveWaffoPancakeUserIDByBuyerEmail(event.Data.BuyerEmail) - if err != nil { - return "", err - } - - amountStr := strings.TrimSpace(string(event.Data.Amount)) - if amountStr == "" { - return "", fmt.Errorf("missing webhook amount") - } - payMoney, err := decimal.NewFromString(amountStr) - if err != nil { - return "", fmt.Errorf("invalid webhook amount %q: %w", amountStr, err) - } - - tolerance := decimal.NewFromFloat(0.000001) - minMoney := payMoney.Sub(tolerance).InexactFloat64() - maxMoney := payMoney.Add(tolerance).InexactFloat64() - - var topUps []*model.TopUp - if err := model.DB. - Where("user_id = ? AND payment_method = ? AND status = ? AND money >= ? AND money <= ?", - userID, "waffo_pancake", common.TopUpStatusPending, minMoney, maxMoney). - Order("create_time desc"). - Limit(2). - Find(&topUps).Error; err != nil { - return "", fmt.Errorf("query pending Waffo Pancake topup failed: %w", err) - } - - if len(topUps) == 0 { - return "", fmt.Errorf("no pending Waffo Pancake topup matched buyerEmail=%s amount=%s", event.Data.BuyerEmail, amountStr) - } - if len(topUps) > 1 { - return "", fmt.Errorf("multiple pending Waffo Pancake topups matched buyerEmail=%s amount=%s", event.Data.BuyerEmail, amountStr) - } - return topUps[0].TradeNo, nil -} - -func resolveWaffoPancakeUserIDByBuyerEmail(email string) (int, error) { - trimmedEmail := strings.TrimSpace(email) - if trimmedEmail == "" { - return 0, fmt.Errorf("missing buyerEmail") - } - - const localSuffix = "@new-api.local" - if strings.HasSuffix(strings.ToLower(trimmedEmail), localSuffix) { - userIDStr := strings.TrimSpace(trimmedEmail[:len(trimmedEmail)-len(localSuffix)]) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - return 0, fmt.Errorf("invalid local buyerEmail %q", trimmedEmail) - } - return userID, nil - } - - var users []*model.User - if err := model.DB.Select("id").Where("email = ?", trimmedEmail).Limit(2).Find(&users).Error; err != nil { - return 0, fmt.Errorf("query user by buyerEmail failed: %w", err) - } - if len(users) == 0 { - return 0, fmt.Errorf("user not found for buyerEmail=%s", trimmedEmail) - } - if len(users) > 1 { - return 0, fmt.Errorf("multiple users matched buyerEmail=%s", trimmedEmail) - } - return users[0].Id, nil -} diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go new file mode 100644 index 000000000000..eeb1012b0766 --- /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) + }) + } +} From b952818343efc6a8254da1a49af0fe9870d7b6d1 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 18 Apr 2026 11:27:21 +0800 Subject: [PATCH 09/13] fix(payment): harden webhook checks and clean HTTP status usage --- controller/payment_webhook_availability.go | 100 +++++++++++ .../payment_webhook_availability_test.go | 166 ++++++++++++++++++ controller/subscription_payment_creem.go | 11 +- controller/topup.go | 64 +++---- controller/topup_creem.go | 24 ++- controller/topup_stripe.go | 28 +-- controller/topup_waffo.go | 40 +++-- controller/topup_waffo_pancake.go | 49 +++--- model/task_cas_test.go | 16 +- 9 files changed, 394 insertions(+), 104 deletions(-) create mode 100644 controller/payment_webhook_availability.go create mode 100644 controller/payment_webhook_availability_test.go diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go new file mode 100644 index 000000000000..9e7a08aea08b --- /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 000000000000..0534acc45f34 --- /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 db70f9b1a100..a5951f0e3c7f 100644 --- a/controller/subscription_payment_creem.go +++ b/controller/subscription_payment_creem.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "log" + "net/http" "time" "github.com/QuantumNous/new-api/common" @@ -25,13 +26,13 @@ func SubscriptionRequestCreemPay(c *gin.Context) { 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"}) + 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 } @@ -90,7 +91,7 @@ func SubscriptionRequestCreemPay(c *gin.Context) { 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 } @@ -115,11 +116,11 @@ func SubscriptionRequestCreemPay(c *gin.Context) { checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username) if err != nil { log.Printf("获取Creem支付链接失败: %v", err) - c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + 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/topup.go b/controller/topup.go index 1b1c0f75004f..699657cc2a1b 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -3,9 +3,9 @@ package controller import ( "fmt" "log" + "net/http" "net/url" "strconv" - "strings" "sync" "time" @@ -28,7 +28,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 { @@ -50,15 +50,7 @@ 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 { @@ -79,15 +71,7 @@ func GetTopUpInfo(c *gin.Context) { } } - enableWaffoPancake := setting.WaffoPancakeEnabled && - strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" && - strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" && - ((!setting.WaffoPancakeSandbox && - strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey) != "") || - (setting.WaffoPancakeSandbox && - strings.TrimSpace(setting.WaffoPancakeWebhookTestKey) != "")) && - strings.TrimSpace(setting.WaffoPancakeStoreID) != "" && - strings.TrimSpace(setting.WaffoPancakeProductID) != "" + enableWaffoPancake := isWaffoPancakeTopUpEnabled() if enableWaffoPancake { hasWaffoPancake := false for _, method := range payMethods { @@ -108,9 +92,9 @@ func GetTopUpInfo(c *gin.Context) { } 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_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{} { @@ -209,28 +193,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 } @@ -241,7 +225,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{ @@ -254,7 +238,7 @@ func RequestEpay(c *gin.Context) { ReturnUrl: returnUrl, }) if err != nil { - c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) return } amount := req.Amount @@ -274,10 +258,10 @@ func RequestEpay(c *gin.Context) { } err = topUp.Insert() if err != nil { - 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": params, "url": uri}) + c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri}) } // tradeNo lock @@ -323,6 +307,12 @@ func UnlockOrder(tradeNo string) { } func EpayNotify(c *gin.Context) { + if !isEpayWebhookEnabled() { + log.Println("在线充值未启用,拒绝处理 webhook") + _, _ = c.Writer.Write([]byte("fail")) + return + } + var params map[string]string if c.Request.Method == "POST" { @@ -419,26 +409,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) { diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 5b1095fecbbd..cf29b383b32d 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -64,12 +64,12 @@ type CreemAdaptor struct { func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { if req.PaymentMethod != model.PaymentMethodCreem { - c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + 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 } @@ -78,7 +78,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { err := json.Unmarshal([]byte(setting.CreemProducts), &products) if err != nil { log.Println("解析Creem产品列表失败", err) - c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"}) return } @@ -92,7 +92,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 } @@ -116,7 +116,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { err = topUp.Insert() if err != nil { log.Printf("创建Creem订单失败: %v", err) - c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) return } @@ -124,14 +124,14 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username) if err != nil { log.Printf("获取Creem支付链接失败: %v", err) - c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + 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) - c.JSON(200, gin.H{ + c.JSON(http.StatusOK, gin.H{ "message": "success", "data": gin.H{ "checkout_url": checkoutUrl, @@ -147,7 +147,7 @@ func RequestCreemPay(c *gin.Context) { 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"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"}) return } @@ -159,7 +159,7 @@ func RequestCreemPay(c *gin.Context) { 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) @@ -227,6 +227,12 @@ type CreemWebhookEvent struct { } func CreemWebhook(c *gin.Context) { + if !isCreemWebhookEnabled() { + log.Printf("Creem 支付未启用,拒绝处理 webhook") + c.AbortWithStatus(http.StatusForbidden) + return + } + // 读取body内容用于打印,同时保留原始数据供后续使用 bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 4d8ea686b6f5..644850198855 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -44,34 +44,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 != model.PaymentMethodStripe { - c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + 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 } @@ -95,7 +95,7 @@ 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": "拉起支付失败"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) return } @@ -110,10 +110,10 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { } err = topUp.Insert() if err != nil { - c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + 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{ "pay_link": payLink, @@ -125,7 +125,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) @@ -135,15 +135,15 @@ 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 未配置,拒绝处理") + if !isStripeWebhookEnabled() { + log.Println("Stripe 支付未启用,拒绝处理 webhook") c.AbortWithStatus(http.StatusForbidden) return } diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index c937b6c17879..1800af6e3679 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -103,54 +103,54 @@ type WaffoPayRequest struct { func RequestWaffoAmount(c *gin.Context) { 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") 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 := 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 } - 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)}) } // 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 } @@ -162,7 +162,7 @@ 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": "不支持的支付方式"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"}) return } resolvedPayMethodType = methods[idx].PayMethodType @@ -180,7 +180,7 @@ 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": "不支持的支付方式"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"}) return } } @@ -189,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 } @@ -218,7 +218,7 @@ func RequestWaffoPay(c *gin.Context) { } if err := topUp.Insert(); err != nil { log.Printf("Waffo 创建本地订单失败: %v", err) - c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) return } @@ -227,7 +227,7 @@ func RequestWaffoPay(c *gin.Context) { log.Printf("Waffo SDK 初始化失败: %v", err) topUp.Status = common.TopUpStatusFailed _ = topUp.Update() - c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"}) return } @@ -271,14 +271,14 @@ func RequestWaffoPay(c *gin.Context) { log.Printf("Waffo 创建订单失败: %v", err) 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) topUp.Status = common.TopUpStatusFailed _ = topUp.Update() - c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) return } @@ -290,7 +290,7 @@ func RequestWaffoPay(c *gin.Context) { paymentUrl = orderData.OrderAction } - c.JSON(200, gin.H{ + c.JSON(http.StatusOK, gin.H{ "message": "success", "data": gin.H{ "payment_url": paymentUrl, @@ -317,6 +317,12 @@ type webhookSubscriptionInfo struct { // WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅) func WaffoWebhook(c *gin.Context) { + if !isWaffoWebhookEnabled() { + log.Printf("Waffo 支付未启用,拒绝处理 webhook") + c.AbortWithStatus(http.StatusForbidden) + return + } + bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { log.Printf("Waffo Webhook 读取 body 失败: %v", err) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index ec536fb476ea..2125e28d6734 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "log" + "net/http" "strings" "time" @@ -25,29 +26,29 @@ type WaffoPancakePayRequest struct { func RequestWaffoPancakeAmount(c *gin.Context) { var req WaffoPancakePayRequest 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 } if req.Amount < int64(setting.WaffoPancakeMinTopUp) { - c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", 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(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) return } payMoney := getWaffoPancakePayMoney(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": fmt.Sprintf("%.2f", payMoney)}) + c.JSON(http.StatusOK, gin.H{"message": "success", "data": fmt.Sprintf("%.2f", payMoney)}) } func getWaffoPancakePayMoney(amount int64, group string) float64 { @@ -114,7 +115,7 @@ func getWaffoPancakeReturnURL() string { func RequestWaffoPancakePay(c *gin.Context) { if !setting.WaffoPancakeEnabled { - c.JSON(200, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"}) return } currentWebhookKey := setting.WaffoPancakeWebhookPublicKey @@ -126,36 +127,36 @@ func RequestWaffoPancakePay(c *gin.Context) { strings.TrimSpace(currentWebhookKey) == "" || strings.TrimSpace(setting.WaffoPancakeStoreID) == "" || strings.TrimSpace(setting.WaffoPancakeProductID) == "" { - c.JSON(200, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"}) return } var req WaffoPancakePayRequest 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 } if req.Amount < int64(setting.WaffoPancakeMinTopUp) { - c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", 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(200, gin.H{"message": "error", "data": "用户不存在"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) return } 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 := getWaffoPancakePayMoney(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 } @@ -171,7 +172,7 @@ func RequestWaffoPancakePay(c *gin.Context) { } if err := topUp.Insert(); err != nil { log.Printf("create Waffo Pancake topup failed: %v", err) - c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) return } @@ -194,11 +195,11 @@ func RequestWaffoPancakePay(c *gin.Context) { log.Printf("create Waffo Pancake checkout session failed: %v", err) topUp.Status = common.TopUpStatusFailed _ = topUp.Update() - c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + 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": session.CheckoutURL, @@ -210,10 +211,16 @@ func RequestWaffoPancakePay(c *gin.Context) { } func WaffoPancakeWebhook(c *gin.Context) { + if !isWaffoPancakeWebhookEnabled() { + log.Printf("Waffo Pancake 支付未启用,拒绝处理 webhook") + c.String(http.StatusForbidden, "webhook disabled") + return + } + bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { log.Printf("read Waffo Pancake webhook body failed: %v", err) - c.String(400, "bad request") + c.String(http.StatusBadRequest, "bad request") return } @@ -222,19 +229,19 @@ func WaffoPancakeWebhook(c *gin.Context) { event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature) if err != nil { log.Printf("verify Waffo Pancake webhook failed: %v", err) - c.String(401, "invalid signature") + c.String(http.StatusUnauthorized, "invalid signature") return } if event.NormalizedEventType() != "order.completed" { - c.String(200, "OK") + c.String(http.StatusOK, "OK") return } tradeNo, err := service.ResolveWaffoPancakeTradeNo(event) if err != nil { log.Printf("Waffo Pancake webhook resolve trade no failed: %v, event=%s, order_id=%s", err, event.ID, event.Data.OrderID) - c.String(200, "OK") + c.String(http.StatusOK, "OK") return } @@ -243,9 +250,9 @@ func WaffoPancakeWebhook(c *gin.Context) { if err := model.RechargeWaffoPancake(tradeNo); err != nil { log.Printf("Waffo Pancake recharge failed: %v, trade_no=%s", err, tradeNo) - c.String(500, "retry") + c.String(http.StatusInternalServerError, "retry") return } - c.String(200, "OK") + c.String(http.StatusOK, "OK") } diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 3449c6d262f7..ba34a73291bc 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") }) } From 140775881599d76b5e2ede854954947aa9cce97a Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 18 Apr 2026 11:38:48 +0800 Subject: [PATCH 10/13] refactor(payment): unify webhook logging with richer traces --- controller/subscription_payment_creem.go | 9 +-- controller/subscription_payment_stripe.go | 4 +- controller/topup.go | 41 ++++++----- controller/topup_creem.go | 88 ++++++++++------------- controller/topup_stripe.go | 82 +++++++++++---------- controller/topup_waffo.go | 41 +++++------ controller/topup_waffo_pancake.go | 20 +++--- 7 files changed, 147 insertions(+), 138 deletions(-) diff --git a/controller/subscription_payment_creem.go b/controller/subscription_payment_creem.go index a5951f0e3c7f..935429acfe04 100644 --- a/controller/subscription_payment_creem.go +++ b/controller/subscription_payment_creem.go @@ -2,12 +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" @@ -25,7 +26,7 @@ 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) + 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 } @@ -113,9 +114,9 @@ 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) + 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 } diff --git a/controller/subscription_payment_stripe.go b/controller/subscription_payment_stripe.go index 4db3cb8801d5..9824c90dc475 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 } diff --git a/controller/topup.go b/controller/topup.go index 699657cc2a1b..86d361a349cb 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -2,7 +2,6 @@ package controller import ( "fmt" - "log" "net/http" "net/url" "strconv" @@ -238,6 +237,7 @@ func RequestEpay(c *gin.Context) { ReturnUrl: returnUrl, }) if err != nil { + 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 } @@ -258,9 +258,11 @@ func RequestEpay(c *gin.Context) { } err = topUp.Insert() if err != nil { + 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 } + 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}) } @@ -308,7 +310,7 @@ func UnlockOrder(tradeNo string) { func EpayNotify(c *gin.Context) { if !isEpayWebhookEnabled() { - log.Println("在线充值未启用,拒绝处理 webhook") + 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 } @@ -318,7 +320,7 @@ func EpayNotify(c *gin.Context) { 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 } @@ -333,58 +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 isNonEpayPaymentMethodForEpayCallback(topUp.PaymentMethod) { - log.Printf("易支付回调订单支付方式不匹配: %s, 订单号: %s", topUp.PaymentMethod, verifyInfo.ServiceTradeNo) + 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.PaymentMethod != verifyInfo.Type { - log.Printf("易支付回调订单支付方式不匹配: order=%s callback=%s, 订单号: %s", topUp.PaymentMethod, verifyInfo.Type, verifyInfo.ServiceTradeNo) + 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) @@ -394,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))) } } diff --git a/controller/topup_creem.go b/controller/topup_creem.go index cf29b383b32d..139dd43fbed1 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" @@ -34,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 @@ -77,7 +78,7 @@ 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) + 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 } @@ -115,21 +116,20 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { } err = topUp.Insert() if err != nil { - log.Printf("创建Creem订单失败: %v", err) + 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) + 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(http.StatusOK, gin.H{ "message": "success", @@ -146,13 +146,12 @@ 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) + 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)) @@ -228,7 +227,7 @@ type CreemWebhookEvent struct { func CreemWebhook(c *gin.Context) { if !isCreemWebhookEnabled() { - log.Printf("Creem 支付未启用,拒绝处理 webhook") + 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 } @@ -236,32 +235,28 @@ func CreemWebhook(c *gin.Context) { // 读取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)) @@ -269,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) } } @@ -290,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 } @@ -298,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 } @@ -307,39 +302,34 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) { LockOrder(referenceId) defer UnlockOrder(referenceId) 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 } @@ -350,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) } @@ -382,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密钥") } @@ -391,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)) } // 构建请求数据,确保包含用户邮箱 @@ -427,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{ @@ -446,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 { @@ -463,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_stripe.go b/controller/topup_stripe.go index 644850198855..23ddb3b90e3e 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" @@ -94,7 +95,7 @@ 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) + 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 } @@ -110,9 +111,11 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { } err = topUp.Insert() if err != nil { + 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 } + 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{ @@ -142,83 +145,86 @@ func RequestStripePay(c *gin.Context) { } func StripeWebhook(c *gin.Context) { + ctx := c.Request.Context() if !isStripeWebhookEnabled() { - log.Println("Stripe 支付未启用,拒绝处理 webhook") + 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 } @@ -227,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 != model.PaymentMethodStripe { - log.Printf("异步支付失败,订单支付方式不匹配: %s, ref: %s", topUp.PaymentMethod, referenceId) + 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 } @@ -265,33 +271,34 @@ func fulfillOrder(event stripe.Event, referenceId string, customerId string, cal "event_type": string(event.Type), } 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 } @@ -299,23 +306,24 @@ func sessionExpired(event stripe.Event) { LockOrder(referenceId) defer UnlockOrder(referenceId) 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 } err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentMethodStripe, common.TopUpStatusExpired) if errors.Is(err, model.ErrTopUpNotFound) { - log.Println("充值订单不存在", referenceId) + logger.LogWarn(ctx, fmt.Sprintf("Stripe 充值订单不存在,无法标记过期 trade_no=%s", referenceId)) return } 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 1800af6e3679..c00680628196 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -4,12 +4,12 @@ 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" @@ -161,7 +161,7 @@ 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)) + 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 } @@ -179,7 +179,7 @@ func RequestWaffoPay(c *gin.Context) { } } if !valid { - log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id) + 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 } @@ -217,14 +217,14 @@ func RequestWaffoPay(c *gin.Context) { Status: common.TopUpStatusPending, } if err := topUp.Insert(); err != nil { - log.Printf("Waffo 创建本地订单失败: %v", err) + 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(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"}) @@ -268,14 +268,14 @@ 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(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(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) @@ -283,7 +283,7 @@ func RequestWaffoPay(c *gin.Context) { } 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 == "" { @@ -318,21 +318,21 @@ type webhookSubscriptionInfo struct { // WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅) func WaffoWebhook(c *gin.Context) { if !isWaffoWebhookEnabled() { - log.Printf("Waffo 支付未启用,拒绝处理 webhook") + 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 } @@ -340,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 } @@ -360,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, "") } } @@ -375,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 err := model.UpdatePendingTopUpStatus(result.MerchantOrderID, model.PaymentMethodWaffo, common.TopUpStatusFailed); err != nil && !errors.Is(err, model.ErrTopUpNotFound) && !errors.Is(err, model.ErrTopUpStatusInvalid) { - log.Printf("Waffo 标记失败订单时处理失败: %v, 订单: %s", err, result.MerchantOrderID) + logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error())) } } sendWaffoWebhookResponse(c, wh, true, "") @@ -394,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 index 2125e28d6734..ce4ce8c768cd 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -3,12 +3,12 @@ package controller import ( "fmt" "io" - "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/service" "github.com/QuantumNous/new-api/setting" @@ -171,7 +171,7 @@ func RequestWaffoPancakePay(c *gin.Context) { Status: common.TopUpStatusPending, } if err := topUp.Insert(); err != nil { - log.Printf("create Waffo Pancake topup failed: %v", err) + 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 } @@ -192,12 +192,13 @@ func RequestWaffoPancakePay(c *gin.Context) { ExpiresInSeconds: &expiresInSeconds, }) if err != nil { - log.Printf("create Waffo Pancake checkout session failed: %v", err) + 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", @@ -212,27 +213,29 @@ func RequestWaffoPancakePay(c *gin.Context) { func WaffoPancakeWebhook(c *gin.Context) { if !isWaffoPancakeWebhookEnabled() { - log.Printf("Waffo Pancake 支付未启用,拒绝处理 webhook") + 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 { - log.Printf("read Waffo Pancake webhook body failed: %v", err) + 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 { - log.Printf("verify Waffo Pancake webhook failed: %v", err) + 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 @@ -240,7 +243,7 @@ func WaffoPancakeWebhook(c *gin.Context) { tradeNo, err := service.ResolveWaffoPancakeTradeNo(event) if err != nil { - log.Printf("Waffo Pancake webhook resolve trade no failed: %v, event=%s, order_id=%s", err, event.ID, event.Data.OrderID) + 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 } @@ -249,10 +252,11 @@ func WaffoPancakeWebhook(c *gin.Context) { defer UnlockOrder(tradeNo) if err := model.RechargeWaffoPancake(tradeNo); err != nil { - log.Printf("Waffo Pancake recharge failed: %v, trade_no=%s", err, tradeNo) + 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") } From b033557244c94988d6ec621d3ba363e7ea65d1c0 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 18 Apr 2026 12:07:00 +0800 Subject: [PATCH 11/13] refactor(payment): align gateway field labels and environment warnings --- web/src/i18n/locales/en.json | 12 ++++++++---- web/src/i18n/locales/fr.json | 12 ++++++++---- web/src/i18n/locales/ja.json | 12 ++++++++---- web/src/i18n/locales/ru.json | 12 ++++++++---- web/src/i18n/locales/vi.json | 12 ++++++++---- web/src/i18n/locales/zh-CN.json | 6 +++++- web/src/i18n/locales/zh-TW.json | 12 ++++++++---- .../Setting/Payment/SettingsPaymentGateway.jsx | 6 +++--- .../Payment/SettingsPaymentGatewayCreem.jsx | 4 ++-- .../Payment/SettingsPaymentGatewayStripe.jsx | 2 +- .../Payment/SettingsPaymentGatewayWaffo.jsx | 17 ++++++----------- .../SettingsPaymentGatewayWaffoPancake.jsx | 6 +++--- 12 files changed, 68 insertions(+), 45 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 495fba961179..7e4db5f36563 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -14,7 +14,9 @@ ",点击更新": ", click Update", "(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个)": "", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Currently only supports Epay interface, the default callback address is the server address above!)", + "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Currently only the Epay interface is supported. Configure the callback address in General Settings.", + "请确认商户和所选环境密钥一致。": "Make sure the merchant and keys for the selected environment match.", + "请确认 Merchant、Store、Product 和所选环境密钥一致。": "Make sure Merchant, Store, Product, and the keys for the selected environment match.", "(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(Input {{input}} tokens / 1M tokens * {{symbol}}{{price}}", @@ -743,6 +745,8 @@ "最低": "lowest", "最低充值数量": "", "最低充值美元数量": "Minimum recharge dollar amount", + "最低充值美元数量必须大于 0": "Minimum recharge dollar amount must be greater than 0", + "留空则自动使用当前站点的默认回调地址": "Leave blank to use the default callback address of the current site", "最后使用时间": "Last used time", "最后更新": "Last Updated", "最后请求": "Last request", @@ -1044,7 +1048,7 @@ "响应缺少凭据": "Response missing credentials", "响应缺少授权链接": "Response missing authorization link", "商品价格 ID": "Product Price ID", - "商户 ID": "", + "商户 ID": "Merchant ID", "回答内容": "Answer Content", "回调 URL 填": "Callback URL Fill", "回调 URL 格式": "Callback URL format", @@ -1712,7 +1716,7 @@ "支付渠道": "Payment Channels", "支付设置": "Payment", "支付请求失败": "Payment request failed", - "支付返回地址": "", + "支付返回地址": "Return URL", "支付金额": "Payment Amount", "支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images", "支持 JSONPath,如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email", @@ -2212,7 +2216,7 @@ "永久删除您的两步验证设置": "Permanently delete your two-factor authentication settings", "永久删除所有备用码(包括未使用的)": "Permanently delete all backup codes (including unused ones)", "汇率": "Exchange rate", - "沙盒模式": "", + "沙盒模式": "Sandbox Mode", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Sandbox RSA private key Base64 (PKCS#8 DER)", "沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Sandbox Waffo public key Base64 (X.509 DER)", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index bdd77c264a97..8e7d143d0954 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -16,7 +16,9 @@ ",点击更新": ", cliquez sur Mettre à jour", "(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个)": "", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Actuellement, seule l'interface Epay est prise en charge, l'adresse du serveur ci-dessus est utilisée par défaut comme adresse de rappel !)", + "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Seule l'interface Epay est actuellement prise en charge. Configurez l'adresse de rappel dans les paramètres généraux.", + "请确认商户和所选环境密钥一致。": "Vérifiez que le marchand et les clés de l'environnement sélectionné correspondent.", + "请确认 Merchant、Store、Product 和所选环境密钥一致。": "Vérifiez que Merchant, Store, Product et les clés de l'environnement sélectionné correspondent.", "(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)", "(筛选后显示 {{count}} 条)_many": "(Affichage de {{count}} éléments après filtrage)", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", @@ -742,6 +744,8 @@ "最低": "Le plus bas", "最低充值数量": "", "最低充值美元数量": "Montant minimum de recharge en dollars", + "最低充值美元数量必须大于 0": "Le montant minimum de recharge en dollars doit être supérieur à 0", + "留空则自动使用当前站点的默认回调地址": "Laissez vide pour utiliser l'adresse de rappel par défaut du site actuel", "最后使用时间": "Dernière utilisation", "最后更新": "Last Updated", "最后请求": "Dernière requête", @@ -1042,7 +1046,7 @@ "响应缺少凭据": "Identifiants manquants dans la réponse", "响应缺少授权链接": "Lien d'autorisation manquant dans la réponse", "商品价格 ID": "ID du prix du produit", - "商户 ID": "", + "商户 ID": "ID marchand", "回答内容": "Contenu de la réponse", "回调 URL 填": "Remplir l'URL de rappel", "回调 URL 格式": "Format de l'URL de rappel", @@ -1712,7 +1716,7 @@ "支付渠道": "Canaux de paiement", "支付设置": "Paiement", "支付请求失败": "Échec de la demande de paiement", - "支付返回地址": "", + "支付返回地址": "URL de retour", "支付金额": "Montant payé", "支持 Ctrl+V 粘贴图片": "Supporte Ctrl+V pour coller l'image", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Prend en charge le code de vérification TOTP à 6 chiffres ou le code de sauvegarde à 8 chiffres, peut être configuré ou consulté dans `Paramètres personnels - Paramètres de sécurité - Paramètres d'authentification à deux facteurs`.", @@ -2203,7 +2207,7 @@ "永久删除您的两步验证设置": "Supprimer définitivement vos paramètres d'authentification à deux facteurs", "永久删除所有备用码(包括未使用的)": "Supprimer définitivement tous les codes de sauvegarde (y compris ceux non utilisés)", "汇率": "Taux de change", - "沙盒模式": "", + "沙盒模式": "Mode bac à sable", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Clé privée RSA Base64 (PKCS#8 DER) de sandbox", "沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Clé publique Waffo Base64 (X.509 DER) de sandbox", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index e6721e36a772..88d2899e17c6 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -14,7 +14,9 @@ ",点击更新": "、クリックして更新してください", "(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个)": "", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(現在、Epay APIのみに対応しています。デフォルトで、上記のサーバーURLがコールバックアドレスとして使用されます。)", + "当前仅支持易支付接口,回调地址请在通用设置中配置。": "現在は Epay API のみ対応しています。コールバックアドレスは一般設定で設定してください。", + "请确认商户和所选环境密钥一致。": "加盟店情報と選択中の環境の鍵が一致していることを確認してください。", + "请确认 Merchant、Store、Product 和所选环境密钥一致。": "Merchant、Store、Product と選択中の環境の鍵が一致していることを確認してください。", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(入力 {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(入力 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + オーディオ入力 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", @@ -729,6 +731,8 @@ "最低": "最低", "最低充值数量": "", "最低充值美元数量": "最低チャージUSD額", + "最低充值美元数量必须大于 0": "最低チャージUSD額は 0 より大きい必要があります", + "留空则自动使用当前站点的默认回调地址": "空欄の場合は現在のサイトのデフォルトのコールバックアドレスを使用します", "最后使用时间": "最終利用日時", "最后更新": "Last Updated", "最后请求": "最終リクエスト日時", @@ -1029,7 +1033,7 @@ "响应缺少凭据": "レスポンスに資格情報がありません", "响应缺少授权链接": "レスポンスに認可リンクがありません", "商品价格 ID": "料金ID", - "商户 ID": "", + "商户 ID": "加盟店 ID", "回答内容": "回答", "回调 URL 填": "コールバックURLを入力してください", "回调 URL 格式": "コールバックURL形式", @@ -1683,7 +1687,7 @@ "支付渠道": "決済チャネル", "支付设置": "決済", "支付请求失败": "決済リクエストに失敗しました", - "支付返回地址": "", + "支付返回地址": "返却先 URL", "支付金额": "決済金額", "支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "6桁のTOTP認証コードまたは8桁のバックアップコードに対応しています。`アカウント設定 - セキュリティ設定 - 2要素認証設定`で設定または確認できます。", @@ -2174,7 +2178,7 @@ "永久删除您的两步验证设置": "2要素認証設定を永久に削除", "永久删除所有备用码(包括未使用的)": "すべてのバックアップコード(未使用分を含む)を永久に削除", "汇率": "為替レート", - "沙盒模式": "", + "沙盒模式": "サンドボックスモード", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "サンドボックス RSA 秘密鍵 Base64 (PKCS#8 DER)", "沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "サンドボックス Waffo 公開鍵 Base64 (X.509 DER)", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 333057e15dac..2980af179f6f 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -18,7 +18,9 @@ ",点击更新": ", нажмите для обновления", "(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个)": "", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(В настоящее время поддерживается только интерфейс YiPay, по умолчанию используется адрес сервера выше в качестве адреса обратного вызова!)", + "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Сейчас поддерживается только интерфейс Epay. Настройте адрес обратного вызова в общих настройках.", + "请确认商户和所选环境密钥一致。": "Убедитесь, что мерчант и ключи выбранной среды совпадают.", + "请确认 Merchant、Store、Product 和所选环境密钥一致。": "Убедитесь, что Merchant, Store, Product и ключи выбранной среды совпадают.", "(筛选后显示 {{count}} 条)_one": "(Showing {{count}} item after filtering)", "(筛选后显示 {{count}} 条)_few": "(Показано {{count}} элемента после фильтрации)", "(筛选后显示 {{count}} 条)_many": "(Показано {{count}} элементов после фильтрации)", @@ -750,6 +752,8 @@ "最低": "Минимум", "最低充值数量": "", "最低充值美元数量": "Минимальная сумма пополнения в долларах", + "最低充值美元数量必须大于 0": "Минимальная сумма пополнения в долларах должна быть больше 0", + "留空则自动使用当前站点的默认回调地址": "Оставьте пустым, чтобы использовать адрес обратного вызова сайта по умолчанию", "最后使用时间": "Время последнего использования", "最后更新": "Last Updated", "最后请求": "Последний запрос", @@ -1050,7 +1054,7 @@ "响应缺少凭据": "В ответе отсутствуют учётные данные", "响应缺少授权链接": "В ответе отсутствует ссылка авторизации", "商品价格 ID": "ID цены товара", - "商户 ID": "", + "商户 ID": "ID мерчанта", "回答内容": "Содержание ответа", "回调 URL 填": "URL обратного вызова", "回调 URL 格式": "Формат URL обратного вызова", @@ -1730,7 +1734,7 @@ "支付渠道": "Платежные каналы", "支付设置": "Оплата", "支付请求失败": "Запрос на оплату не удался", - "支付返回地址": "", + "支付返回地址": "Адрес возврата", "支付金额": "Сумма оплаты", "支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Поддерживает 6-значные TOTP коды подтверждения или 8-значные резервные коды, можно настроить или просмотреть в `Личные настройки-Настройки безопасности-Настройки двухфакторной аутентификации`.", @@ -2221,7 +2225,7 @@ "永久删除您的两步验证设置": "Окончательно удалить настройки двухфакторной аутентификации", "永久删除所有备用码(包括未使用的)": "Окончательно удалить все резервные коды (включая неиспользованные)", "汇率": "Обменный курс", - "沙盒模式": "", + "沙盒模式": "Режим песочницы", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "RSA закрытый ключ Base64 (PKCS#8 DER) песочницы", "沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Открытый ключ Waffo Base64 (X.509 DER) песочницы", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index a35ec4549aad..4ca1a77f3122 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -14,7 +14,9 @@ ",点击更新": ", nhấn để cập nhật", "(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个)": "", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Hiện tại chỉ hỗ trợ giao diện Epay, địa chỉ máy chủ phía trên được sử dụng làm địa chỉ gọi lại theo mặc định!)", + "当前仅支持易支付接口,回调地址请在通用设置中配置。": "Hiện tại chỉ hỗ trợ giao diện Epay. Hãy cấu hình địa chỉ gọi lại trong cài đặt chung.", + "请确认商户和所选环境密钥一致。": "Hãy đảm bảo merchant và khóa của môi trường đã chọn khớp nhau.", + "请确认 Merchant、Store、Product 和所选环境密钥一致。": "Hãy đảm bảo Merchant, Store, Product và khóa của môi trường đã chọn khớp nhau.", "(筛选后显示 {{count}} 条)_other": "(Showing {{count}} items after filtering)", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(Đầu vào {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(Đầu vào {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + Đầu vào âm thanh {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", @@ -730,6 +732,8 @@ "最低": "thấp nhất", "最低充值数量": "", "最低充值美元数量": "Số tiền nạp đô la tối thiểu", + "最低充值美元数量必须大于 0": "Số tiền nạp đô la tối thiểu phải lớn hơn 0", + "留空则自动使用当前站点的默认回调地址": "Để trống để dùng địa chỉ gọi lại mặc định của trang hiện tại", "最后使用时间": "Thời gian sử dụng cuối cùng", "最后更新": "Last Updated", "最后请求": "Yêu cầu cuối cùng", @@ -1030,7 +1034,7 @@ "响应缺少凭据": "Phản hồi thiếu thông tin xác thực", "响应缺少授权链接": "Phản hồi thiếu liên kết xác thực", "商品价格 ID": "ID giá sản phẩm", - "商户 ID": "", + "商户 ID": "ID người bán", "回答内容": "Nội dung trả lời", "回调 URL 填": "Điền URL gọi lại", "回调 URL 格式": "Định dạng URL callback", @@ -1684,7 +1688,7 @@ "支付渠道": "Kênh thanh toán", "支付设置": "Thanh toán", "支付请求失败": "Yêu cầu thanh toán thất bại", - "支付返回地址": "", + "支付返回地址": "URL trả về", "支付金额": "Số tiền thanh toán", "支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh", "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Hỗ trợ mã xác minh TOTP 6 chữ số hoặc mã dự phòng 8 chữ số, có thể được cấu hình hoặc xem trong `Cài đặt cá nhân - Cài đặt bảo mật - Cài đặt xác thực hai yếu tố`.", @@ -2211,7 +2215,7 @@ "永久删除所有备用码(包括未使用的)": "Xóa vĩnh viễn tất cả các mã dự phòng (bao gồm cả mã chưa sử dụng)", "永久有效": "Có hiệu lực vĩnh viễn", "汇率": "Tỷ giá hối đoái", - "沙盒模式": "", + "沙盒模式": "Chế độ Sandbox", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "Khóa riêng RSA Base64 (PKCS#8 DER) môi trường sandbox", "沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "Khóa công khai Waffo Base64 (X.509 DER) môi trường sandbox", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 31257b95d327..8c52cdfbba75 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -12,7 +12,9 @@ ",点击更新": ",点击更新", "(共 {{total}} 个,省略 {{omit}} 个)": "(共 {{total}} 个,省略 {{omit}} 个)", "(共 {{total}} 个)": "(共 {{total}} 个)", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)", + "当前仅支持易支付接口,回调地址请在通用设置中配置。": "当前仅支持易支付接口,回调地址请在通用设置中配置。", + "请确认商户和所选环境密钥一致。": "请确认商户和所选环境密钥一致。", + "请确认 Merchant、Store、Product 和所选环境密钥一致。": "请确认 Merchant、Store、Product 和所选环境密钥一致。", "(筛选后显示 {{count}} 条)_other": "(筛选后显示 {{count}} 条)", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", @@ -718,6 +720,8 @@ "最低": "最低", "最低充值数量": "最低充值数量", "最低充值美元数量": "最低充值美元数量", + "最低充值美元数量必须大于 0": "最低充值美元数量必须大于 0", + "留空则自动使用当前站点的默认回调地址": "留空则自动使用当前站点的默认回调地址", "最后使用时间": "最后使用时间", "最后更新": "最后更新", "最后请求": "最后请求", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index a7727f89402c..b31c9e1e0eac 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -12,7 +12,9 @@ ",点击更新": ",點擊更新", "(共 {{total}} 个,省略 {{omit}} 个)": "", "(共 {{total}} 个)": "", - "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(當前僅支援易支付接口,預設使用上方伺服器位址作為回調位址!)", + "当前仅支持易支付接口,回调地址请在通用设置中配置。": "目前僅支援易支付接口,回調位址請在通用設定中配置。", + "请确认商户和所选环境密钥一致。": "請確認商戶與所選環境密鑰一致。", + "请确认 Merchant、Store、Product 和所选环境密钥一致。": "請確認 Merchant、Store、Product 與所選環境密鑰一致。", "(筛选后显示 {{count}} 条)_other": "(篩選後顯示 {{count}} 條)", "(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}": "(輸入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}", "(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}": "(輸入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音訊輸入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}", @@ -726,6 +728,8 @@ "最低": "最低", "最低充值数量": "", "最低充值美元数量": "最低儲值美元數量", + "最低充值美元数量必须大于 0": "最低儲值美元數量必須大於 0", + "留空则自动使用当前站点的默认回调地址": "留空則自動使用目前站點的預設回調位址", "最后使用时间": "最後使用時間", "最后更新": "最後更新", "最后请求": "最後請求", @@ -1027,7 +1031,7 @@ "响应缺少凭据": "", "响应缺少授权链接": "", "商品价格 ID": "商品價格 ID", - "商户 ID": "", + "商户 ID": "商戶 ID", "回答内容": "回答內容", "回调 URL 填": "回調 URL 填", "回调 URL 格式": "回調 URL 格式", @@ -1683,7 +1687,7 @@ "支付渠道": "支付管道", "支付设置": "支付設定", "支付请求失败": "支付請求失敗", - "支付返回地址": "", + "支付返回地址": "支付返回位址", "支付金额": "支付金額", "支持 Ctrl+V 粘贴图片": "支援 Ctrl+V 貼上圖片", "支持 JSONPath,如 email, data.user.email": "支援 JSONPath,如 email, data.user.email", @@ -2181,7 +2185,7 @@ "永久删除您的两步验证设置": "永久刪除您的兩步驗證設定", "永久删除所有备用码(包括未使用的)": "永久刪除所有備用碼(包括未使用的)", "汇率": "匯率", - "沙盒模式": "", + "沙盒模式": "沙盒模式", "沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "沙盒環境 RSA 私鑰 Base64 (PKCS#8 DER)", "沙盒环境 Waffo API 密钥": "", "沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "沙盒環境 Waffo 公鑰 Base64 (X.509 DER)", diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index 76b7e9c38492..d0398a240a53 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -127,7 +127,7 @@ export default function SettingsPaymentGateway(props) { type='info' icon={} description={t( - '当前仅支持易支付接口,默认使用上方服务器地址作为回调地址。', + '当前仅支持易支付接口,回调地址请在通用设置中配置。', )} style={{ marginBottom: 16 }} /> @@ -142,14 +142,14 @@ export default function SettingsPaymentGateway(props) { diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx index 0c0a8059da98..dc1c310ea3a5 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx @@ -289,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 a200007de0e5..c8d3da2e4022 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx @@ -182,7 +182,7 @@ export default function SettingsPaymentGateway(props) { 完成联调。
- Webhook: + {t('回调地址')}: {props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')} diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx index afc8fe31ba0c..46802820bd18 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayWaffo.jsx @@ -65,7 +65,6 @@ export default function SettingsPaymentGatewayWaffo(props) { }); const formApiRef = useRef(null); const iconFileInputRef = useRef(null); - const isSandboxMode = !!inputs.WaffoSandbox; const handleIconFileChange = (e) => { const file = e.target.files[0]; @@ -370,7 +369,7 @@ export default function SettingsPaymentGatewayWaffo(props) { 进行配置,切换沙盒模式时请同步填写对应环境的密钥。
- Webhook: + {t('回调地址')}: {props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')} @@ -382,11 +381,7 @@ export default function SettingsPaymentGatewayWaffo(props) { } - description={ - isSandboxMode - ? t('当前显示的是测试环境配置,请确认商户和测试环境密钥一致。') - : t('当前显示的是生产环境配置,请确认商户和生产环境密钥一致。') - } + description={t('请确认商户和所选环境密钥一致。')} style={{ marginBottom: 16 }} /> @@ -543,9 +538,9 @@ export default function SettingsPaymentGatewayWaffo(props) {
@@ -667,7 +662,7 @@ export default function SettingsPaymentGatewayWaffo(props) {
- {t('Pay Method Type')} + {t('支付方式类型')}
- {t('Pay Method Name')} + {t('支付方式名称')}
获取,建议先在测试环境完成联调。
- Webhook: + {t('回调地址')}: {props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')} @@ -260,7 +260,7 @@ export default function SettingsPaymentGatewayWaffoPancake(props) { type='warning' icon={} description={t( - '请确认 Merchant、Store、Product 和对应环境密钥一致。', + '请确认 Merchant、Store、Product 和所选环境密钥一致。', )} style={{ marginBottom: 16 }} /> From 1a967bf317e452e764116d2b532acefc78de9a7a Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 18 Apr 2026 12:11:40 +0800 Subject: [PATCH 12/13] fix(payment): correct waffo pancake amount format and add tests --- controller/topup_waffo_pancake.go | 9 +-- controller/topup_waffo_pancake_test.go | 91 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 controller/topup_waffo_pancake_test.go diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index ce4ce8c768cd..81515a56ed35 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -89,11 +89,8 @@ func normalizeWaffoPancakeTopUpAmount(amount int64) int64 { return normalized } -func waffoPancakeMoneyToMinorUnits(payMoney float64) int64 { - return decimal.NewFromFloat(payMoney). - Mul(decimal.NewFromInt(100)). - Round(0). - IntPart() +func formatWaffoPancakeAmount(payMoney float64) string { + return decimal.NewFromFloat(payMoney).StringFixed(2) } func getWaffoPancakeBuyerEmail(user *model.User) string { @@ -183,7 +180,7 @@ func RequestWaffoPancakePay(c *gin.Context) { ProductType: "onetime", Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)), PriceSnapshot: &service.WaffoPancakePriceSnapshot{ - Amount: fmt.Sprintf("%d", waffoPancakeMoneyToMinorUnits(payMoney)), + Amount: formatWaffoPancakeAmount(payMoney), TaxIncluded: false, TaxCategory: "saas", }, diff --git a/controller/topup_waffo_pancake_test.go b/controller/topup_waffo_pancake_test.go new file mode 100644 index 000000000000..483dd7b793fd --- /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) + }) + } +} From 3bcee6fe27f1ecea95abf24d7c57a02929d4e746 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 18 Apr 2026 12:51:08 +0800 Subject: [PATCH 13/13] =?UTF-8?q?=E6=9A=82=E6=97=B6=E7=A6=81=E7=94=A8waffo?= =?UTF-8?q?=20pancake=20pay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- router/api-router.go | 6 +++--- web/src/components/settings/PaymentSetting.jsx | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/router/api-router.go b/router/api-router.go index 4696dc6009a9..83f5e4ae9d92 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -49,7 +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) + //apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook) // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) @@ -93,8 +93,8 @@ func SetApiRouter(router *gin.Engine) { 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("/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/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index a04a679daaa5..080c3e6e0ba9 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -198,13 +198,13 @@ const PaymentSetting = () => { hideSectionTitle /> - - - + {/**/} + {/* */} + {/**/}