| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380 |
- 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("%[email protected]", 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)
- 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))
- }
|