package controller import ( "errors" "fmt" "io" "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" "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 } func RequestWaffoAmount(c *gin.Context) { var req WaffoPayRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) return } waffoMinTopup := int64(setting.WaffoMinTopUp) if req.Amount < waffoMinTopup { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)}) return } id := c.GetInt("id") group, err := model.GetUserGroup(id, true) if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) return } payMoney := getWaffoPayMoney(float64(req.Amount), group) if payMoney <= 0.01 { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) return } c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) } // RequestWaffoPay 创建 Waffo 支付订单 func RequestWaffoPay(c *gin.Context) { if !setting.WaffoEnabled { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo 支付未启用"}) return } var req WaffoPayRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) return } waffoMinTopup := int64(setting.WaffoMinTopUp) if req.Amount < waffoMinTopup { c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)}) return } id := c.GetInt("id") user, err := model.GetUserById(id, false) if err != nil || user == nil { c.JSON(http.StatusOK, 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) { logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods))) c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"}) return } resolvedPayMethodType = methods[idx].PayMethodType 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 { 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 } } // resolvedPayMethodType/Name 为空时,Waffo 自动选择支付方式 group, _ := model.GetUserGroup(id, true) payMoney := getWaffoPayMoney(float64(req.Amount), group) if payMoney < 0.01 { c.JSON(http.StatusOK, 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: model.PaymentMethodWaffo, PaymentProvider: model.PaymentProviderWaffo, CreateTime: time.Now().Unix(), Status: common.TopUpStatusPending, } if err := topUp.Insert(); err != nil { 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 { 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": "支付配置错误"}) 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 { 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() { 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": "拉起支付失败"}) return } orderData := resp.GetData() logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName)) paymentUrl := orderData.FetchRedirectURL() if paymentUrl == "" { paymentUrl = orderData.OrderAction } c.JSON(http.StatusOK, 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) { if !isWaffoWebhookEnabled() { logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP())) c.AbortWithStatus(http.StatusForbidden) return } bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { 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 { 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 } 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) { 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 { 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 } switch event.EventType { case core.EventPayment: // 解析为扩展类型,区分普通支付和订阅支付 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 } 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: logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 忽略事件 event_type=%s client_ip=%s", event.EventType, c.ClientIP())) sendWaffoWebhookResponse(c, wh, true, "") } } // handleWaffoPayment 处理支付完成通知 func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) { if result.OrderStatus != "PAY_SUCCESS" { 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.PaymentProviderWaffo, common.TopUpStatusFailed); err != nil && !errors.Is(err, model.ErrTopUpNotFound) && !errors.Is(err, model.ErrTopUpStatusInvalid) { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error())) } } sendWaffoWebhookResponse(c, wh, true, "") return } merchantOrderId := result.MerchantOrderID LockOrder(merchantOrderId) defer UnlockOrder(merchantOrderId) if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil { 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 } logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值成功 trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP())) 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)) }