topup_waffo.go 15 KB

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