Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions controller/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"net/url"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -78,24 +79,50 @@ func GetTopUpInfo(c *gin.Context) {
}
}

enableWaffoPancake := setting.WaffoPancakeEnabled &&
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
Comment on lines +82 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Include webhook public-key readiness in enableWaffoPancake gating.

Current checks only validate merchant/private/store/product IDs. If webhook verify key is missing, users can pay but callback verification may fail and quota won’t be credited.

🔐 Suggested gating update
 enableWaffoPancake := setting.WaffoPancakeEnabled &&
   strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
   strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
   strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
-  strings.TrimSpace(setting.WaffoPancakeProductID) != ""
+  strings.TrimSpace(setting.WaffoPancakeProductID) != "" &&
+  ((!setting.WaffoPancakeSandbox &&
+    strings.TrimSpace(setting.WaffoPancakeProdWebhookPublicKey) != "") ||
+    (setting.WaffoPancakeSandbox &&
+      strings.TrimSpace(setting.WaffoPancakeTestWebhookPublicKey) != ""))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
enableWaffoPancake := setting.WaffoPancakeEnabled &&
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
enableWaffoPancake := setting.WaffoPancakeEnabled &&
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
strings.TrimSpace(setting.WaffoPancakeProductID) != "" &&
((!setting.WaffoPancakeSandbox &&
strings.TrimSpace(setting.WaffoPancakeProdWebhookPublicKey) != "") ||
(setting.WaffoPancakeSandbox &&
strings.TrimSpace(setting.WaffoPancakeTestWebhookPublicKey) != ""))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/topup.go` around lines 82 - 86, The enableWaffoPancake boolean
currently only checks merchant/private/store/product IDs; include the webhook
public-key readiness in this gating by adding a non-empty trimmed check for the
webhook public key (e.g. strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
!= "") into the same expression that defines enableWaffoPancake so callback
verification won't be skipped when the key is missing; update the
enableWaffoPancake assignment accordingly (and optionally add a log/warning
elsewhere if the webhook key is missing).

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)
}
Expand Down Expand Up @@ -463,4 +490,3 @@ func AdminCompleteTopUp(c *gin.Context) {
}
common.ApiSuccess(c, nil)
}

228 changes: 228 additions & 0 deletions controller/topup_waffo_pancake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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 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
}
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
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: 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")
if signature == "" {
signature = c.GetHeader("X-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)
Comment on lines +218 to +219
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other webhook handlers in the codebase handle locking
rg -n -A5 "LockOrder|UnlockOrder" --type=go

Repository: QuantumNous/new-api

Length of output: 5240


🏁 Script executed:

# View the complete LockOrder/UnlockOrder implementation
sed -n '278,310p' controller/topup.go

Repository: QuantumNous/new-api

Length of output: 678


🏁 Script executed:

# Check if there's documentation or deployment configuration
fd -e md -e yaml -e yml -e json -e toml | head -20

Repository: QuantumNous/new-api

Length of output: 459


🏁 Script executed:

# Search for deployment or distributed deployment references
rg -i "(distributed|deployment|multi.instance|cluster)" --type=go -l | head -10

Repository: QuantumNous/new-api

Length of output: 312


🏁 Script executed:

# Check if Redis is configured and used elsewhere in the codebase
rg -i "redis" --type=go -l | head -10

Repository: QuantumNous/new-api

Length of output: 282


🏁 Script executed:

# Check docker-compose.yml for multi-instance setup
cat docker-compose.yml

Repository: QuantumNous/new-api

Length of output: 3338


🏁 Script executed:

# Check README for deployment guidance
head -100 README.md | grep -i -A5 "deploy\|instance\|distributed"

Repository: QuantumNous/new-api

Length of output: 201


🏁 Script executed:

# Search for any comments about single-instance assumptions in topup.go
rg -B5 -A5 "LockOrder|orderLocks" controller/topup.go | head -50

Repository: QuantumNous/new-api

Length of output: 953


In-process locking is insufficient for distributed deployments.

The LockOrder/UnlockOrder implementation uses a process-local sync.Map. The docker-compose.yml includes a comment indicating multi-node deployment is a supported scenario (SESSION_SECRET=random_string # multi-node deployment, set this to a random string). If multiple instances are deployed, concurrent webhook requests for the same tradeNo across instances can race.

This pattern is pervasive across all webhook handlers (Epay, Stripe, Creem, Waffo, etc.). For multi-instance deployments, switch to database-level locking (e.g., SELECT ... FOR UPDATE) or Redis-based locking (Redis is already in the stack). If single-instance deployment is the only supported mode, explicitly document this constraint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/topup_waffo_pancake.go` around lines 218 - 219, The current
in-process LockOrder/UnlockOrder (used in handlers like the Waffo webhook where
LockOrder(tradeNo) and defer UnlockOrder(tradeNo) are called) uses a
process-local sync.Map and won’t prevent races in multi-instance deployments;
replace this with a distributed lock (preferred: Redis-based lock using the
existing Redis in the stack, or DB row-level locking via a SELECT ... FOR UPDATE
inside a transaction that scopes the webhook processing) so that concurrent
webhook requests for the same tradeNo across instances are serialized;
alternatively, if you intend to keep the process-local LockOrder/UnlockOrder,
add clear documentation of the single-instance deployment constraint and change
usages (e.g., in the Waffo handler) to use the new distributed lock API instead
of LockOrder/UnlockOrder.


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")
}
36 changes: 36 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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["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["WaffoPancakeProdWebhookPublicKey"] = setting.WaffoPancakeProdWebhookPublicKey
common.OptionMap["WaffoPancakeTestWebhookPublicKey"] = setting.WaffoPancakeTestWebhookPublicKey
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
Expand Down Expand Up @@ -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 "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 "WaffoPancakeProdWebhookPublicKey":
setting.WaffoPancakeProdWebhookPublicKey = value
case "WaffoPancakeTestWebhookPublicKey":
setting.WaffoPancakeTestWebhookPublicKey = value
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":
Expand Down
Loading