topup_waffo.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. package controller
  2. import (
  3. "fmt"
  4. "io"
  5. "log"
  6. "net/http"
  7. "strconv"
  8. "time"
  9. "github.com/QuantumNous/new-api/common"
  10. "github.com/QuantumNous/new-api/model"
  11. "github.com/QuantumNous/new-api/service"
  12. "github.com/QuantumNous/new-api/setting"
  13. "github.com/QuantumNous/new-api/setting/operation_setting"
  14. "github.com/QuantumNous/new-api/setting/system_setting"
  15. "github.com/gin-gonic/gin"
  16. "github.com/thanhpk/randstr"
  17. waffo "github.com/waffo-com/waffo-go"
  18. "github.com/waffo-com/waffo-go/config"
  19. "github.com/waffo-com/waffo-go/core"
  20. "github.com/waffo-com/waffo-go/types/order"
  21. )
  22. func getWaffoSDK() (*waffo.Waffo, error) {
  23. env := config.Sandbox
  24. apiKey := setting.WaffoSandboxApiKey
  25. privateKey := setting.WaffoSandboxPrivateKey
  26. publicKey := setting.WaffoSandboxPublicCert
  27. if !setting.WaffoSandbox {
  28. env = config.Production
  29. apiKey = setting.WaffoApiKey
  30. privateKey = setting.WaffoPrivateKey
  31. publicKey = setting.WaffoPublicCert
  32. }
  33. builder := config.NewConfigBuilder().
  34. APIKey(apiKey).
  35. PrivateKey(privateKey).
  36. WaffoPublicKey(publicKey).
  37. Environment(env)
  38. if setting.WaffoMerchantId != "" {
  39. builder = builder.MerchantID(setting.WaffoMerchantId)
  40. }
  41. cfg, err := builder.Build()
  42. if err != nil {
  43. return nil, err
  44. }
  45. return waffo.New(cfg), nil
  46. }
  47. func getWaffoUserEmail(user *model.User) string {
  48. return fmt.Sprintf("%d@examples.com", user.Id)
  49. }
  50. func getWaffoCurrency() string {
  51. if setting.WaffoCurrency != "" {
  52. return setting.WaffoCurrency
  53. }
  54. return "USD"
  55. }
  56. // zeroDecimalCurrencies 零小数位币种,金额不能带小数点
  57. var zeroDecimalCurrencies = map[string]bool{
  58. "IDR": true, "JPY": true, "KRW": true, "VND": true,
  59. }
  60. func formatWaffoAmount(amount float64, currency string) string {
  61. if zeroDecimalCurrencies[currency] {
  62. return fmt.Sprintf("%.0f", amount)
  63. }
  64. return fmt.Sprintf("%.2f", amount)
  65. }
  66. // getWaffoPayMoney converts the user-facing amount to USD for Waffo payment.
  67. // Waffo only accepts USD, so this function handles the conversion from different
  68. // display types (USD/CNY/TOKENS) to the actual USD amount to charge.
  69. func getWaffoPayMoney(amount float64, group string) float64 {
  70. originalAmount := amount
  71. if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
  72. amount = amount / common.QuotaPerUnit
  73. }
  74. topupGroupRatio := common.GetTopupGroupRatio(group)
  75. if topupGroupRatio == 0 {
  76. topupGroupRatio = 1
  77. }
  78. discount := 1.0
  79. if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
  80. if ds > 0 {
  81. discount = ds
  82. }
  83. }
  84. return amount * setting.WaffoUnitPrice * topupGroupRatio * discount
  85. }
  86. type WaffoPayRequest struct {
  87. Amount int64 `json:"amount"`
  88. PayMethodIndex *int `json:"pay_method_index"` // 服务端支付方式列表的索引,nil 表示由 Waffo 自动选择
  89. PayMethodType string `json:"pay_method_type"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
  90. PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
  91. }
  92. // RequestWaffoPay 创建 Waffo 支付订单
  93. func RequestWaffoPay(c *gin.Context) {
  94. if !setting.WaffoEnabled {
  95. c.JSON(200, gin.H{"message": "error", "data": "Waffo 支付未启用"})
  96. return
  97. }
  98. var req WaffoPayRequest
  99. if err := c.ShouldBindJSON(&req); err != nil {
  100. c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
  101. return
  102. }
  103. waffoMinTopup := int64(setting.WaffoMinTopUp)
  104. if req.Amount < waffoMinTopup {
  105. c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
  106. return
  107. }
  108. id := c.GetInt("id")
  109. user, err := model.GetUserById(id, false)
  110. if err != nil || user == nil {
  111. c.JSON(200, gin.H{"message": "error", "data": "用户不存在"})
  112. return
  113. }
  114. // 从服务端配置查找支付方式,客户端只传索引或旧字段
  115. var resolvedPayMethodType, resolvedPayMethodName string
  116. methods := setting.GetWaffoPayMethods()
  117. if req.PayMethodIndex != nil {
  118. // 新协议:按索引查找
  119. idx := *req.PayMethodIndex
  120. if idx < 0 || idx >= len(methods) {
  121. log.Printf("Waffo 无效的支付方式索引: %d, UserId=%d, 可用范围: [0, %d)", idx, id, len(methods))
  122. c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
  123. return
  124. }
  125. resolvedPayMethodType = methods[idx].PayMethodType
  126. resolvedPayMethodName = methods[idx].PayMethodName
  127. } else if req.PayMethodType != "" {
  128. // 兼容旧前端:验证客户端传的值在服务端列表中
  129. valid := false
  130. for _, m := range methods {
  131. if m.PayMethodType == req.PayMethodType && m.PayMethodName == req.PayMethodName {
  132. valid = true
  133. resolvedPayMethodType = m.PayMethodType
  134. resolvedPayMethodName = m.PayMethodName
  135. break
  136. }
  137. }
  138. if !valid {
  139. log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id)
  140. c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
  141. return
  142. }
  143. }
  144. // resolvedPayMethodType/Name 为空时,Waffo 自动选择支付方式
  145. group, _ := model.GetUserGroup(id, true)
  146. payMoney := getWaffoPayMoney(float64(req.Amount), group)
  147. if payMoney < 0.01 {
  148. c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
  149. return
  150. }
  151. // 生成唯一订单号,paymentRequestId 与 merchantOrderId 保持一致,简化追踪
  152. merchantOrderId := fmt.Sprintf("WAFFO-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
  153. paymentRequestId := merchantOrderId
  154. // Token 模式下归一化 Amount(存等价美元/CNY 数量,避免 RechargeWaffo 双重放大)
  155. amount := req.Amount
  156. if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
  157. amount = int64(float64(req.Amount) / common.QuotaPerUnit)
  158. if amount < 1 {
  159. amount = 1
  160. }
  161. }
  162. // 创建本地订单
  163. topUp := &model.TopUp{
  164. UserId: id,
  165. Amount: amount,
  166. Money: payMoney,
  167. TradeNo: merchantOrderId,
  168. PaymentMethod: "waffo",
  169. CreateTime: time.Now().Unix(),
  170. Status: common.TopUpStatusPending,
  171. }
  172. if err := topUp.Insert(); err != nil {
  173. log.Printf("Waffo 创建本地订单失败: %v", err)
  174. c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
  175. return
  176. }
  177. sdk, err := getWaffoSDK()
  178. if err != nil {
  179. log.Printf("Waffo SDK 初始化失败: %v", err)
  180. topUp.Status = common.TopUpStatusFailed
  181. _ = topUp.Update()
  182. c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"})
  183. return
  184. }
  185. callbackAddr := service.GetCallbackAddress()
  186. notifyUrl := callbackAddr + "/api/waffo/webhook"
  187. if setting.WaffoNotifyUrl != "" {
  188. notifyUrl = setting.WaffoNotifyUrl
  189. }
  190. returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true"
  191. if setting.WaffoReturnUrl != "" {
  192. returnUrl = setting.WaffoReturnUrl
  193. }
  194. currency := getWaffoCurrency()
  195. createParams := &order.CreateOrderParams{
  196. PaymentRequestID: paymentRequestId,
  197. MerchantOrderID: merchantOrderId,
  198. OrderAmount: formatWaffoAmount(payMoney, currency),
  199. OrderCurrency: currency,
  200. OrderDescription: fmt.Sprintf("Recharge %d credits", req.Amount),
  201. OrderRequestedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
  202. NotifyURL: notifyUrl,
  203. MerchantInfo: &order.MerchantInfo{
  204. MerchantID: setting.WaffoMerchantId,
  205. },
  206. UserInfo: &order.UserInfo{
  207. UserID: strconv.Itoa(user.Id),
  208. UserEmail: getWaffoUserEmail(user),
  209. UserTerminal: "WEB",
  210. },
  211. PaymentInfo: &order.PaymentInfo{
  212. ProductName: "ONE_TIME_PAYMENT",
  213. PayMethodType: resolvedPayMethodType,
  214. PayMethodName: resolvedPayMethodName,
  215. },
  216. SuccessRedirectURL: returnUrl,
  217. FailedRedirectURL: returnUrl,
  218. }
  219. resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
  220. if err != nil {
  221. log.Printf("Waffo 创建订单失败: %v", err)
  222. topUp.Status = common.TopUpStatusFailed
  223. _ = topUp.Update()
  224. c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
  225. return
  226. }
  227. if !resp.IsSuccess() {
  228. log.Printf("Waffo 创建订单业务失败: [%s] %s, 完整响应: %+v", resp.Code, resp.Message, resp)
  229. topUp.Status = common.TopUpStatusFailed
  230. _ = topUp.Update()
  231. c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
  232. return
  233. }
  234. orderData := resp.GetData()
  235. log.Printf("Waffo 订单创建成功 - 用户: %d, 订单: %s, 金额: %.2f", id, merchantOrderId, payMoney)
  236. // 存储 gatewayOrderId,退款时直接使用;保存失败则中止,避免付款后无法退款
  237. if orderData.AcquiringOrderID != "" {
  238. if err := topUp.Update(); err != nil {
  239. log.Printf("Waffo 保存 gatewayOrderId 失败: %v, 订单: %s", err, merchantOrderId)
  240. topUp.Status = common.TopUpStatusFailed
  241. _ = topUp.Update()
  242. c.JSON(200, gin.H{"message": "error", "data": "创建订单失败,请重试"})
  243. return
  244. }
  245. }
  246. paymentUrl := orderData.FetchRedirectURL()
  247. if paymentUrl == "" {
  248. paymentUrl = orderData.OrderAction
  249. }
  250. c.JSON(200, gin.H{
  251. "message": "success",
  252. "data": gin.H{
  253. "payment_url": paymentUrl,
  254. "order_id": merchantOrderId,
  255. },
  256. })
  257. }
  258. // webhookPayloadWithSubInfo 扩展 PAYMENT_NOTIFICATION,包含 SDK 未定义的 subscriptionInfo 字段
  259. type webhookPayloadWithSubInfo struct {
  260. EventType string `json:"eventType"`
  261. Result struct {
  262. core.PaymentNotificationResult
  263. SubscriptionInfo *webhookSubscriptionInfo `json:"subscriptionInfo,omitempty"`
  264. } `json:"result"`
  265. }
  266. type webhookSubscriptionInfo struct {
  267. Period string `json:"period,omitempty"`
  268. MerchantRequest string `json:"merchantRequest,omitempty"`
  269. SubscriptionID string `json:"subscriptionId,omitempty"`
  270. SubscriptionRequest string `json:"subscriptionRequest,omitempty"`
  271. }
  272. // WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
  273. func WaffoWebhook(c *gin.Context) {
  274. bodyBytes, err := io.ReadAll(c.Request.Body)
  275. if err != nil {
  276. log.Printf("Waffo Webhook 读取 body 失败: %v", err)
  277. c.AbortWithStatus(http.StatusBadRequest)
  278. return
  279. }
  280. sdk, err := getWaffoSDK()
  281. if err != nil {
  282. log.Printf("Waffo Webhook SDK 初始化失败: %v", err)
  283. c.AbortWithStatus(http.StatusInternalServerError)
  284. return
  285. }
  286. wh := sdk.Webhook()
  287. bodyStr := string(bodyBytes)
  288. signature := c.GetHeader("X-SIGNATURE")
  289. // 验证请求签名
  290. if !wh.VerifySignature(bodyStr, signature) {
  291. log.Printf("Waffo webhook 签名验证失败")
  292. c.AbortWithStatus(http.StatusBadRequest)
  293. return
  294. }
  295. var event core.WebhookEvent
  296. if err := common.Unmarshal(bodyBytes, &event); err != nil {
  297. log.Printf("Waffo Webhook 解析失败: %v", err)
  298. sendWaffoWebhookResponse(c, wh, false, "invalid payload")
  299. return
  300. }
  301. switch event.EventType {
  302. case core.EventPayment:
  303. // 解析为扩展类型,区分普通支付和订阅支付
  304. var payload webhookPayloadWithSubInfo
  305. if err := common.Unmarshal(bodyBytes, &payload); err != nil {
  306. sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
  307. return
  308. }
  309. log.Printf("Waffo Webhook - EventType: %s, MerchantOrderId: %s, OrderStatus: %s",
  310. event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus)
  311. handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
  312. default:
  313. log.Printf("Waffo Webhook 未知事件: %s", event.EventType)
  314. sendWaffoWebhookResponse(c, wh, true, "")
  315. }
  316. }
  317. // handleWaffoPayment 处理支付完成通知
  318. func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
  319. if result.OrderStatus != "PAY_SUCCESS" {
  320. log.Printf("Waffo 订单状态非成功: %s, 订单: %s", result.OrderStatus, result.MerchantOrderID)
  321. // 终态失败订单标记为 failed,避免永远停在 pending
  322. if result.MerchantOrderID != "" {
  323. if topUp := model.GetTopUpByTradeNo(result.MerchantOrderID); topUp != nil &&
  324. topUp.Status == common.TopUpStatusPending {
  325. topUp.Status = common.TopUpStatusFailed
  326. _ = topUp.Update()
  327. }
  328. }
  329. sendWaffoWebhookResponse(c, wh, true, "")
  330. return
  331. }
  332. merchantOrderId := result.MerchantOrderID
  333. LockOrder(merchantOrderId)
  334. defer UnlockOrder(merchantOrderId)
  335. if err := model.RechargeWaffo(merchantOrderId); err != nil {
  336. log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
  337. sendWaffoWebhookResponse(c, wh, false, err.Error())
  338. return
  339. }
  340. log.Printf("Waffo 充值成功 - 订单: %s", merchantOrderId)
  341. sendWaffoWebhookResponse(c, wh, true, "")
  342. }
  343. // sendWaffoWebhookResponse 发送签名响应
  344. func sendWaffoWebhookResponse(c *gin.Context, wh *core.WebhookHandler, success bool, msg string) {
  345. var body, sig string
  346. if success {
  347. body, sig = wh.BuildSuccessResponse()
  348. } else {
  349. body, sig = wh.BuildFailedResponse(msg)
  350. }
  351. c.Header("X-SIGNATURE", sig)
  352. c.Data(http.StatusOK, "application/json", []byte(body))
  353. }