The code read orderData.AcquiringOrderID but never assigned it to any TopUp field before calling Update(), making the block a no-op. Removed since GatewayOrderId storage is not needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
381 lines
12 KiB
Go
381 lines
12 KiB
Go
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)
|
||
|
||
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))
|
||
}
|