diff --git a/common/constants.go b/common/constants.go
index 6823b2c8..6a3ddfa4 100644
--- a/common/constants.go
+++ b/common/constants.go
@@ -211,5 +211,6 @@ const (
const (
TopUpStatusPending = "pending"
TopUpStatusSuccess = "success"
+ TopUpStatusFailed = "failed"
TopUpStatusExpired = "expired"
)
diff --git a/constant/waffo_pay_method.go b/constant/waffo_pay_method.go
new file mode 100644
index 00000000..0cee72a8
--- /dev/null
+++ b/constant/waffo_pay_method.go
@@ -0,0 +1,16 @@
+package constant
+
+// WaffoPayMethod defines the display and API parameter mapping for Waffo payment methods.
+type WaffoPayMethod struct {
+ Name string `json:"name"` // Frontend display name
+ Icon string `json:"icon"` // Frontend icon identifier: credit-card, apple, google
+ PayMethodType string `json:"payMethodType"` // Waffo API PayMethodType, can be comma-separated
+ PayMethodName string `json:"payMethodName"` // Waffo API PayMethodName, empty means auto-select by Waffo checkout
+}
+
+// DefaultWaffoPayMethods is the default list of supported payment methods.
+var DefaultWaffoPayMethods = []WaffoPayMethod{
+ {Name: "Card", Icon: "/pay-card.png", PayMethodType: "CREDITCARD,DEBITCARD", PayMethodName: ""},
+ {Name: "Apple Pay", Icon: "/pay-apple.png", PayMethodType: "APPLEPAY", PayMethodName: "APPLEPAY"},
+ {Name: "Google Pay", Icon: "/pay-google.png", PayMethodType: "GOOGLEPAY", PayMethodName: "GOOGLEPAY"},
+}
diff --git a/controller/topup.go b/controller/topup.go
index a810eba7..e7a392a4 100644
--- a/controller/topup.go
+++ b/controller/topup.go
@@ -48,14 +48,52 @@ 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 != ""))
+ if enableWaffo {
+ hasWaffo := false
+ for _, method := range payMethods {
+ if method["type"] == "waffo" {
+ hasWaffo = true
+ break
+ }
+ }
+
+ if !hasWaffo {
+ waffoMethod := map[string]string{
+ "name": "Waffo (Global Payment)",
+ "type": "waffo",
+ "color": "rgba(var(--semi-blue-5), 1)",
+ "min_topup": strconv.Itoa(setting.WaffoMinTopUp),
+ }
+ payMethods = append(payMethods, waffoMethod)
+ }
+ }
+
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 != "[]",
- "creem_products": setting.CreemProducts,
+ "enable_waffo_topup": enableWaffo,
+ "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,
}
@@ -204,27 +242,42 @@ func RequestEpay(c *gin.Context) {
var orderLocks sync.Map
var createLock sync.Mutex
+// refCountedMutex 带引用计数的互斥锁,确保最后一个使用者才从 map 中删除
+type refCountedMutex struct {
+ mu sync.Mutex
+ refCount int
+}
+
// LockOrder 尝试对给定订单号加锁
func LockOrder(tradeNo string) {
- lock, ok := orderLocks.Load(tradeNo)
- if !ok {
- createLock.Lock()
- defer createLock.Unlock()
- lock, ok = orderLocks.Load(tradeNo)
- if !ok {
- lock = new(sync.Mutex)
- orderLocks.Store(tradeNo, lock)
- }
+ createLock.Lock()
+ var rcm *refCountedMutex
+ if v, ok := orderLocks.Load(tradeNo); ok {
+ rcm = v.(*refCountedMutex)
+ } else {
+ rcm = &refCountedMutex{}
+ orderLocks.Store(tradeNo, rcm)
}
- lock.(*sync.Mutex).Lock()
+ rcm.refCount++
+ createLock.Unlock()
+ rcm.mu.Lock()
}
// UnlockOrder 释放给定订单号的锁
func UnlockOrder(tradeNo string) {
- lock, ok := orderLocks.Load(tradeNo)
- if ok {
- lock.(*sync.Mutex).Unlock()
+ v, ok := orderLocks.Load(tradeNo)
+ if !ok {
+ return
}
+ rcm := v.(*refCountedMutex)
+ rcm.mu.Unlock()
+
+ createLock.Lock()
+ rcm.refCount--
+ if rcm.refCount == 0 {
+ orderLocks.Delete(tradeNo)
+ }
+ createLock.Unlock()
}
func EpayNotify(c *gin.Context) {
@@ -410,3 +463,4 @@ func AdminCompleteTopUp(c *gin.Context) {
}
common.ApiSuccess(c, nil)
}
+
diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go
new file mode 100644
index 00000000..d78bb314
--- /dev/null
+++ b/controller/topup_waffo.go
@@ -0,0 +1,391 @@
+package controller
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strconv"
+ "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/thanhpk/randstr"
+ waffo "github.com/waffo-com/waffo-go"
+ "github.com/waffo-com/waffo-go/config"
+ "github.com/waffo-com/waffo-go/core"
+ "github.com/waffo-com/waffo-go/types/order"
+)
+
+func getWaffoSDK() (*waffo.Waffo, error) {
+ env := config.Sandbox
+ apiKey := setting.WaffoSandboxApiKey
+ privateKey := setting.WaffoSandboxPrivateKey
+ publicKey := setting.WaffoSandboxPublicCert
+ if !setting.WaffoSandbox {
+ env = config.Production
+ apiKey = setting.WaffoApiKey
+ privateKey = setting.WaffoPrivateKey
+ publicKey = setting.WaffoPublicCert
+ }
+ builder := config.NewConfigBuilder().
+ APIKey(apiKey).
+ PrivateKey(privateKey).
+ WaffoPublicKey(publicKey).
+ Environment(env)
+ if setting.WaffoMerchantId != "" {
+ builder = builder.MerchantID(setting.WaffoMerchantId)
+ }
+ cfg, err := builder.Build()
+ if err != nil {
+ return nil, err
+ }
+ return waffo.New(cfg), nil
+}
+
+func getWaffoUserEmail(user *model.User) string {
+ return fmt.Sprintf("%d@examples.com", user.Id)
+}
+
+func getWaffoCurrency() string {
+ if setting.WaffoCurrency != "" {
+ return setting.WaffoCurrency
+ }
+ return "USD"
+}
+
+// zeroDecimalCurrencies 零小数位币种,金额不能带小数点
+var zeroDecimalCurrencies = map[string]bool{
+ "IDR": true, "JPY": true, "KRW": true, "VND": true,
+}
+
+func formatWaffoAmount(amount float64, currency string) string {
+ if zeroDecimalCurrencies[currency] {
+ return fmt.Sprintf("%.0f", amount)
+ }
+ return fmt.Sprintf("%.2f", amount)
+}
+
+// getWaffoPayMoney converts the user-facing amount to USD for Waffo payment.
+// Waffo only accepts USD, so this function handles the conversion from different
+// display types (USD/CNY/TOKENS) to the actual USD amount to charge.
+func getWaffoPayMoney(amount float64, group string) float64 {
+ originalAmount := amount
+ if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
+ amount = amount / common.QuotaPerUnit
+ }
+ topupGroupRatio := common.GetTopupGroupRatio(group)
+ if topupGroupRatio == 0 {
+ topupGroupRatio = 1
+ }
+ discount := 1.0
+ if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
+ if ds > 0 {
+ discount = ds
+ }
+ }
+ return amount * setting.WaffoUnitPrice * topupGroupRatio * discount
+}
+
+type WaffoPayRequest struct {
+ Amount int64 `json:"amount"`
+ PayMethodIndex *int `json:"pay_method_index"` // 服务端支付方式列表的索引,nil 表示由 Waffo 自动选择
+ PayMethodType string `json:"pay_method_type"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
+ PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
+}
+
+// RequestWaffoPay 创建 Waffo 支付订单
+func RequestWaffoPay(c *gin.Context) {
+ if !setting.WaffoEnabled {
+ c.JSON(200, 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": "参数错误"})
+ 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")
+ user, err := model.GetUserById(id, false)
+ if err != nil || user == nil {
+ c.JSON(200, gin.H{"message": "error", "data": "用户不存在"})
+ return
+ }
+
+ // 从服务端配置查找支付方式,客户端只传索引或旧字段
+ var resolvedPayMethodType, resolvedPayMethodName string
+ methods := setting.GetWaffoPayMethods()
+ if req.PayMethodIndex != nil {
+ // 新协议:按索引查找
+ 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": "不支持的支付方式"})
+ return
+ }
+ resolvedPayMethodType = methods[idx].PayMethodType
+ resolvedPayMethodName = methods[idx].PayMethodName
+ } else if req.PayMethodType != "" {
+ // 兼容旧前端:验证客户端传的值在服务端列表中
+ valid := false
+ for _, m := range methods {
+ if m.PayMethodType == req.PayMethodType && m.PayMethodName == req.PayMethodName {
+ valid = true
+ resolvedPayMethodType = m.PayMethodType
+ resolvedPayMethodName = m.PayMethodName
+ break
+ }
+ }
+ if !valid {
+ log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id)
+ c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
+ return
+ }
+ }
+ // resolvedPayMethodType/Name 为空时,Waffo 自动选择支付方式
+
+ group, _ := model.GetUserGroup(id, true)
+ payMoney := getWaffoPayMoney(float64(req.Amount), group)
+ if payMoney < 0.01 {
+ c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
+ return
+ }
+
+ // 生成唯一订单号,paymentRequestId 与 merchantOrderId 保持一致,简化追踪
+ merchantOrderId := fmt.Sprintf("WAFFO-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
+ paymentRequestId := merchantOrderId
+
+ // Token 模式下归一化 Amount(存等价美元/CNY 数量,避免 RechargeWaffo 双重放大)
+ amount := req.Amount
+ if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
+ amount = int64(float64(req.Amount) / common.QuotaPerUnit)
+ if amount < 1 {
+ amount = 1
+ }
+ }
+
+ // 创建本地订单
+ topUp := &model.TopUp{
+ UserId: id,
+ Amount: amount,
+ Money: payMoney,
+ TradeNo: merchantOrderId,
+ PaymentMethod: "waffo",
+ CreateTime: time.Now().Unix(),
+ Status: common.TopUpStatusPending,
+ }
+ if err := topUp.Insert(); err != nil {
+ log.Printf("Waffo 创建本地订单失败: %v", err)
+ c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
+ return
+ }
+
+ sdk, err := getWaffoSDK()
+ if err != nil {
+ log.Printf("Waffo SDK 初始化失败: %v", err)
+ topUp.Status = common.TopUpStatusFailed
+ _ = topUp.Update()
+ c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"})
+ return
+ }
+
+ callbackAddr := service.GetCallbackAddress()
+ notifyUrl := callbackAddr + "/api/waffo/webhook"
+ if setting.WaffoNotifyUrl != "" {
+ notifyUrl = setting.WaffoNotifyUrl
+ }
+ returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true"
+ if setting.WaffoReturnUrl != "" {
+ returnUrl = setting.WaffoReturnUrl
+ }
+
+ currency := getWaffoCurrency()
+ createParams := &order.CreateOrderParams{
+ PaymentRequestID: paymentRequestId,
+ MerchantOrderID: merchantOrderId,
+ OrderAmount: formatWaffoAmount(payMoney, currency),
+ OrderCurrency: currency,
+ OrderDescription: fmt.Sprintf("Recharge %d credits", req.Amount),
+ OrderRequestedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
+ NotifyURL: notifyUrl,
+ MerchantInfo: &order.MerchantInfo{
+ MerchantID: setting.WaffoMerchantId,
+ },
+ UserInfo: &order.UserInfo{
+ UserID: strconv.Itoa(user.Id),
+ UserEmail: getWaffoUserEmail(user),
+ UserTerminal: "WEB",
+ },
+ PaymentInfo: &order.PaymentInfo{
+ ProductName: "ONE_TIME_PAYMENT",
+ PayMethodType: resolvedPayMethodType,
+ PayMethodName: resolvedPayMethodName,
+ },
+ SuccessRedirectURL: returnUrl,
+ FailedRedirectURL: returnUrl,
+ }
+ resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
+ if err != nil {
+ log.Printf("Waffo 创建订单失败: %v", err)
+ topUp.Status = common.TopUpStatusFailed
+ _ = topUp.Update()
+ c.JSON(200, 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": "拉起支付失败"})
+ return
+ }
+
+ orderData := resp.GetData()
+ log.Printf("Waffo 订单创建成功 - 用户: %d, 订单: %s, 金额: %.2f", id, merchantOrderId, payMoney)
+
+ // 存储 gatewayOrderId,退款时直接使用;保存失败则中止,避免付款后无法退款
+ if orderData.AcquiringOrderID != "" {
+ if err := topUp.Update(); err != nil {
+ log.Printf("Waffo 保存 gatewayOrderId 失败: %v, 订单: %s", err, merchantOrderId)
+ topUp.Status = common.TopUpStatusFailed
+ _ = topUp.Update()
+ c.JSON(200, gin.H{"message": "error", "data": "创建订单失败,请重试"})
+ return
+ }
+ }
+
+ paymentUrl := orderData.FetchRedirectURL()
+ if paymentUrl == "" {
+ paymentUrl = orderData.OrderAction
+ }
+
+ c.JSON(200, gin.H{
+ "message": "success",
+ "data": gin.H{
+ "payment_url": paymentUrl,
+ "order_id": merchantOrderId,
+ },
+ })
+}
+
+// webhookPayloadWithSubInfo 扩展 PAYMENT_NOTIFICATION,包含 SDK 未定义的 subscriptionInfo 字段
+type webhookPayloadWithSubInfo struct {
+ EventType string `json:"eventType"`
+ Result struct {
+ core.PaymentNotificationResult
+ SubscriptionInfo *webhookSubscriptionInfo `json:"subscriptionInfo,omitempty"`
+ } `json:"result"`
+}
+
+type webhookSubscriptionInfo struct {
+ Period string `json:"period,omitempty"`
+ MerchantRequest string `json:"merchantRequest,omitempty"`
+ SubscriptionID string `json:"subscriptionId,omitempty"`
+ SubscriptionRequest string `json:"subscriptionRequest,omitempty"`
+}
+
+// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
+func WaffoWebhook(c *gin.Context) {
+ bodyBytes, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ log.Printf("Waffo Webhook 读取 body 失败: %v", err)
+ c.AbortWithStatus(http.StatusBadRequest)
+ return
+ }
+
+ sdk, err := getWaffoSDK()
+ if err != nil {
+ log.Printf("Waffo Webhook SDK 初始化失败: %v", err)
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ wh := sdk.Webhook()
+ bodyStr := string(bodyBytes)
+ signature := c.GetHeader("X-SIGNATURE")
+
+ // 验证请求签名
+ if !wh.VerifySignature(bodyStr, signature) {
+ log.Printf("Waffo webhook 签名验证失败")
+ c.AbortWithStatus(http.StatusBadRequest)
+ return
+ }
+
+ var event core.WebhookEvent
+ if err := common.Unmarshal(bodyBytes, &event); err != nil {
+ log.Printf("Waffo Webhook 解析失败: %v", err)
+ sendWaffoWebhookResponse(c, wh, false, "invalid payload")
+ return
+ }
+
+ switch event.EventType {
+ case core.EventPayment:
+ // 解析为扩展类型,区分普通支付和订阅支付
+ var payload webhookPayloadWithSubInfo
+ if err := common.Unmarshal(bodyBytes, &payload); err != nil {
+ 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)
+ handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
+ default:
+ log.Printf("Waffo Webhook 未知事件: %s", event.EventType)
+ sendWaffoWebhookResponse(c, wh, true, "")
+ }
+}
+
+// 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)
+ // 终态失败订单标记为 failed,避免永远停在 pending
+ if result.MerchantOrderID != "" {
+ if topUp := model.GetTopUpByTradeNo(result.MerchantOrderID); topUp != nil &&
+ topUp.Status == common.TopUpStatusPending {
+ topUp.Status = common.TopUpStatusFailed
+ _ = topUp.Update()
+ }
+ }
+ sendWaffoWebhookResponse(c, wh, true, "")
+ return
+ }
+
+ merchantOrderId := result.MerchantOrderID
+
+ LockOrder(merchantOrderId)
+ defer UnlockOrder(merchantOrderId)
+
+ if err := model.RechargeWaffo(merchantOrderId); err != nil {
+ log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
+ sendWaffoWebhookResponse(c, wh, false, err.Error())
+ return
+ }
+
+ log.Printf("Waffo 充值成功 - 订单: %s", merchantOrderId)
+ sendWaffoWebhookResponse(c, wh, true, "")
+}
+
+// sendWaffoWebhookResponse 发送签名响应
+func sendWaffoWebhookResponse(c *gin.Context, wh *core.WebhookHandler, success bool, msg string) {
+ var body, sig string
+ if success {
+ body, sig = wh.BuildSuccessResponse()
+ } else {
+ body, sig = wh.BuildFailedResponse(msg)
+ }
+ c.Header("X-SIGNATURE", sig)
+ c.Data(http.StatusOK, "application/json", []byte(body))
+}
diff --git a/go.mod b/go.mod
index 2f28f781..f7aaaa9a 100644
--- a/go.mod
+++ b/go.mod
@@ -46,6 +46,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
+ github.com/waffo-com/waffo-go v1.3.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.45.0
golang.org/x/image v0.23.0
@@ -120,7 +121,6 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/samber/go-singleflightx v0.3.2 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
diff --git a/go.sum b/go.sum
index 74298929..4adbe980 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g=
@@ -10,34 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
-github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
-github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
-github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
-github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
-github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
-github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -58,7 +44,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -132,12 +117,13 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -186,8 +172,6 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
-github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -245,7 +229,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -262,8 +245,9 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/samber/go-singleflightx v0.3.2 h1:jXbUU0fvis8Fdv4HGONboX5WdEZcYLoBEcKiE+ITCyQ=
github.com/samber/go-singleflightx v0.3.2/go.mod h1:X2BR+oheHIYc73PvxRMlcASg6KYYTQyUYpdVU7t/ux4=
github.com/samber/hot v0.11.0 h1:JhV9hk8SmZIqB0To8OyCzPubvszkuoSXWx/7FCEGO+Q=
@@ -320,6 +304,8 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw=
+github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -330,6 +316,8 @@ github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFi
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -339,14 +327,12 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -367,19 +353,14 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
-google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/model/option.go b/model/option.go
index 697e77df..967fa0aa 100644
--- a/model/option.go
+++ b/model/option.go
@@ -89,6 +89,22 @@ func InitOptionMap() {
common.OptionMap["CreemProducts"] = setting.CreemProducts
common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode)
common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret
+ common.OptionMap["WaffoEnabled"] = strconv.FormatBool(setting.WaffoEnabled)
+ common.OptionMap["WaffoApiKey"] = setting.WaffoApiKey
+ common.OptionMap["WaffoPrivateKey"] = setting.WaffoPrivateKey
+ common.OptionMap["WaffoPublicCert"] = setting.WaffoPublicCert
+ common.OptionMap["WaffoSandboxPublicCert"] = setting.WaffoSandboxPublicCert
+ common.OptionMap["WaffoSandboxApiKey"] = setting.WaffoSandboxApiKey
+ common.OptionMap["WaffoSandboxPrivateKey"] = setting.WaffoSandboxPrivateKey
+ common.OptionMap["WaffoSandbox"] = strconv.FormatBool(setting.WaffoSandbox)
+ common.OptionMap["WaffoMerchantId"] = setting.WaffoMerchantId
+ common.OptionMap["WaffoNotifyUrl"] = setting.WaffoNotifyUrl
+ common.OptionMap["WaffoReturnUrl"] = setting.WaffoReturnUrl
+ common.OptionMap["WaffoSubscriptionReturnUrl"] = setting.WaffoSubscriptionReturnUrl
+ common.OptionMap["WaffoCurrency"] = setting.WaffoCurrency
+ common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
+ common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
+ common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -358,6 +374,36 @@ func updateOptionMap(key string, value string) (err error) {
setting.CreemTestMode = value == "true"
case "CreemWebhookSecret":
setting.CreemWebhookSecret = value
+ case "WaffoEnabled":
+ setting.WaffoEnabled = value == "true"
+ case "WaffoApiKey":
+ setting.WaffoApiKey = value
+ case "WaffoPrivateKey":
+ setting.WaffoPrivateKey = value
+ case "WaffoPublicCert":
+ setting.WaffoPublicCert = value
+ case "WaffoSandboxPublicCert":
+ setting.WaffoSandboxPublicCert = value
+ case "WaffoSandboxApiKey":
+ setting.WaffoSandboxApiKey = value
+ case "WaffoSandboxPrivateKey":
+ setting.WaffoSandboxPrivateKey = value
+ case "WaffoSandbox":
+ setting.WaffoSandbox = value == "true"
+ case "WaffoMerchantId":
+ setting.WaffoMerchantId = value
+ case "WaffoNotifyUrl":
+ setting.WaffoNotifyUrl = value
+ case "WaffoReturnUrl":
+ setting.WaffoReturnUrl = value
+ case "WaffoSubscriptionReturnUrl":
+ setting.WaffoSubscriptionReturnUrl = value
+ case "WaffoCurrency":
+ setting.WaffoCurrency = value
+ case "WaffoUnitPrice":
+ setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
+ case "WaffoMinTopUp":
+ setting.WaffoMinTopUp, _ = strconv.Atoi(value)
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":
@@ -458,6 +504,10 @@ func updateOptionMap(key string, value string) (err error) {
setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
case "PayMethods":
err = operation_setting.UpdatePayMethodsByJsonString(value)
+ case "WaffoPayMethods":
+ // WaffoPayMethods is read directly from OptionMap via setting.GetWaffoPayMethods().
+ // The value is already stored in OptionMap at the top of this function (line: common.OptionMap[key] = value).
+ // No additional in-memory variable to update.
}
return err
}
diff --git a/model/topup.go b/model/topup.go
index 655d9b77..d8c92bfe 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 {
@@ -376,3 +376,62 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return nil
}
+
+func RechargeWaffo(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("充值订单状态错误")
+ }
+
+ dAmount := decimal.NewFromInt(topUp.Amount)
+ dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
+ quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).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 topup failed: " + err.Error())
+ return errors.New("充值失败,请稍后重试")
+ }
+
+ if quotaToAdd > 0 {
+ RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
+ }
+
+ return nil
+}
diff --git a/router/api-router.go b/router/api-router.go
index 9836083d..91013fca 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -48,6 +48,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
+ apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
@@ -89,6 +90,7 @@ 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/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
diff --git a/setting/payment_waffo.go b/setting/payment_waffo.go
new file mode 100644
index 00000000..c27ca6f2
--- /dev/null
+++ b/setting/payment_waffo.go
@@ -0,0 +1,67 @@
+package setting
+
+import (
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+)
+
+var (
+ WaffoEnabled bool
+ WaffoApiKey string
+ WaffoPrivateKey string
+ WaffoPublicCert string
+ WaffoSandboxPublicCert string
+ WaffoSandboxApiKey string
+ WaffoSandboxPrivateKey string
+ WaffoSandbox bool
+ WaffoMerchantId string
+ WaffoNotifyUrl string
+ WaffoReturnUrl string
+ WaffoSubscriptionReturnUrl string
+ WaffoCurrency string
+ WaffoUnitPrice float64 = 1.0
+ WaffoMinTopUp int = 1
+)
+
+// GetWaffoPayMethods 从 options 读取 Waffo 支付方式配置
+func GetWaffoPayMethods() []constant.WaffoPayMethod {
+ common.OptionMapRWMutex.RLock()
+ jsonStr := common.OptionMap["WaffoPayMethods"]
+ common.OptionMapRWMutex.RUnlock()
+
+ if jsonStr == "" {
+ return copyDefaultWaffoPayMethods()
+ }
+ var methods []constant.WaffoPayMethod
+ if err := common.UnmarshalJsonStr(jsonStr, &methods); err != nil {
+ return copyDefaultWaffoPayMethods()
+ }
+ return methods
+}
+
+// SetWaffoPayMethods 序列化 Waffo 支付方式配置并更新 OptionMap
+func SetWaffoPayMethods(methods []constant.WaffoPayMethod) error {
+ jsonBytes, err := common.Marshal(methods)
+ if err != nil {
+ return err
+ }
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap["WaffoPayMethods"] = string(jsonBytes)
+ common.OptionMapRWMutex.Unlock()
+ return nil
+}
+
+func copyDefaultWaffoPayMethods() []constant.WaffoPayMethod {
+ cp := make([]constant.WaffoPayMethod, len(constant.DefaultWaffoPayMethods))
+ copy(cp, constant.DefaultWaffoPayMethods)
+ return cp
+}
+
+// WaffoPayMethods2JsonString 将默认 WaffoPayMethods 序列化为 JSON 字符串(供 InitOptionMap 使用)
+func WaffoPayMethods2JsonString() string {
+ jsonBytes, err := common.Marshal(constant.DefaultWaffoPayMethods)
+ if err != nil {
+ return "[]"
+ }
+ return string(jsonBytes)
+}
diff --git a/web/public/pay-apple.png b/web/public/pay-apple.png
new file mode 100644
index 00000000..b040f6e8
Binary files /dev/null and b/web/public/pay-apple.png differ
diff --git a/web/public/pay-card.png b/web/public/pay-card.png
new file mode 100644
index 00000000..98c41461
Binary files /dev/null and b/web/public/pay-card.png differ
diff --git a/web/public/pay-google.png b/web/public/pay-google.png
new file mode 100644
index 00000000..bd31bb92
Binary files /dev/null and b/web/public/pay-google.png differ
diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx
index 28cbf13b..928d58a7 100644
--- a/web/src/components/settings/PaymentSetting.jsx
+++ b/web/src/components/settings/PaymentSetting.jsx
@@ -23,6 +23,7 @@ import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralP
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
+import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
@@ -66,7 +67,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
- console.error('解析TopupGroupRatio出错:', error);
newInputs[item.key] = item.value;
}
break;
@@ -78,7 +78,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
- console.error('解析AmountOptions出错:', error);
newInputs['AmountOptions'] = item.value;
}
break;
@@ -90,7 +89,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
- console.error('解析AmountDiscount出错:', error);
newInputs['AmountDiscount'] = item.value;
}
break;
@@ -146,6 +144,9 @@ const PaymentSetting = () => {