topup_creem.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. package controller
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/hex"
  8. "encoding/json"
  9. "errors"
  10. "fmt"
  11. "github.com/QuantumNous/new-api/common"
  12. "github.com/QuantumNous/new-api/logger"
  13. "github.com/QuantumNous/new-api/model"
  14. "github.com/QuantumNous/new-api/setting"
  15. "io"
  16. "net/http"
  17. "time"
  18. "github.com/gin-gonic/gin"
  19. "github.com/thanhpk/randstr"
  20. )
  21. const CreemSignatureHeader = "creem-signature"
  22. var creemAdaptor = &CreemAdaptor{}
  23. // 生成HMAC-SHA256签名
  24. func generateCreemSignature(payload string, secret string) string {
  25. h := hmac.New(sha256.New, []byte(secret))
  26. h.Write([]byte(payload))
  27. return hex.EncodeToString(h.Sum(nil))
  28. }
  29. // 验证Creem webhook签名
  30. func verifyCreemSignature(payload string, signature string, secret string) bool {
  31. if secret == "" {
  32. logger.LogWarn(context.Background(), fmt.Sprintf("Creem webhook secret 未配置 test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
  33. if setting.CreemTestMode {
  34. logger.LogInfo(context.Background(), fmt.Sprintf("Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q", signature, payload))
  35. return true
  36. }
  37. return false
  38. }
  39. expectedSignature := generateCreemSignature(payload, secret)
  40. return hmac.Equal([]byte(signature), []byte(expectedSignature))
  41. }
  42. type CreemPayRequest struct {
  43. ProductId string `json:"product_id"`
  44. PaymentMethod string `json:"payment_method"`
  45. }
  46. type CreemProduct struct {
  47. ProductId string `json:"productId"`
  48. Name string `json:"name"`
  49. Price float64 `json:"price"`
  50. Currency string `json:"currency"`
  51. Quota int64 `json:"quota"`
  52. }
  53. type CreemAdaptor struct {
  54. }
  55. func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
  56. if req.PaymentMethod != model.PaymentMethodCreem {
  57. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
  58. return
  59. }
  60. if req.ProductId == "" {
  61. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "请选择产品"})
  62. return
  63. }
  64. // 解析产品列表
  65. var products []CreemProduct
  66. err := json.Unmarshal([]byte(setting.CreemProducts), &products)
  67. if err != nil {
  68. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 产品配置解析失败 user_id=%d error=%q", c.GetInt("id"), err.Error()))
  69. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"})
  70. return
  71. }
  72. // 查找对应的产品
  73. var selectedProduct *CreemProduct
  74. for _, product := range products {
  75. if product.ProductId == req.ProductId {
  76. selectedProduct = &product
  77. break
  78. }
  79. }
  80. if selectedProduct == nil {
  81. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
  82. return
  83. }
  84. id := c.GetInt("id")
  85. user, _ := model.GetUserById(id, false)
  86. // 生成唯一的订单引用ID
  87. reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
  88. referenceId := "ref_" + common.Sha1([]byte(reference))
  89. // 先创建订单记录,使用产品配置的金额和充值额度
  90. topUp := &model.TopUp{
  91. UserId: id,
  92. Amount: selectedProduct.Quota, // 充值额度
  93. Money: selectedProduct.Price, // 支付金额
  94. TradeNo: referenceId,
  95. PaymentMethod: model.PaymentMethodCreem,
  96. PaymentProvider: model.PaymentProviderCreem,
  97. CreateTime: time.Now().Unix(),
  98. Status: common.TopUpStatusPending,
  99. }
  100. err = topUp.Insert()
  101. if err != nil {
  102. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
  103. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
  104. return
  105. }
  106. // 创建支付链接,传入用户邮箱
  107. checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, selectedProduct, user.Email, user.Username)
  108. if err != nil {
  109. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
  110. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
  111. return
  112. }
  113. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
  114. c.JSON(http.StatusOK, gin.H{
  115. "message": "success",
  116. "data": gin.H{
  117. "checkout_url": checkoutUrl,
  118. "order_id": referenceId,
  119. },
  120. })
  121. }
  122. func RequestCreemPay(c *gin.Context) {
  123. var req CreemPayRequest
  124. // 读取body内容用于打印,同时保留原始数据供后续使用
  125. bodyBytes, err := io.ReadAll(c.Request.Body)
  126. if err != nil {
  127. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 支付请求读取失败 error=%q", err.Error()))
  128. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
  129. return
  130. }
  131. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付请求已收到 user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
  132. // 重新设置body供后续的ShouldBindJSON使用
  133. c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
  134. err = c.ShouldBindJSON(&req)
  135. if err != nil {
  136. c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
  137. return
  138. }
  139. creemAdaptor.RequestPay(c, &req)
  140. }
  141. // 新的Creem Webhook结构体,匹配实际的webhook数据格式
  142. type CreemWebhookEvent struct {
  143. Id string `json:"id"`
  144. EventType string `json:"eventType"`
  145. CreatedAt int64 `json:"created_at"`
  146. Object struct {
  147. Id string `json:"id"`
  148. Object string `json:"object"`
  149. RequestId string `json:"request_id"`
  150. Order struct {
  151. Object string `json:"object"`
  152. Id string `json:"id"`
  153. Customer string `json:"customer"`
  154. Product string `json:"product"`
  155. Amount int `json:"amount"`
  156. Currency string `json:"currency"`
  157. SubTotal int `json:"sub_total"`
  158. TaxAmount int `json:"tax_amount"`
  159. AmountDue int `json:"amount_due"`
  160. AmountPaid int `json:"amount_paid"`
  161. Status string `json:"status"`
  162. Type string `json:"type"`
  163. Transaction string `json:"transaction"`
  164. CreatedAt string `json:"created_at"`
  165. UpdatedAt string `json:"updated_at"`
  166. Mode string `json:"mode"`
  167. } `json:"order"`
  168. Product struct {
  169. Id string `json:"id"`
  170. Object string `json:"object"`
  171. Name string `json:"name"`
  172. Description string `json:"description"`
  173. Price int `json:"price"`
  174. Currency string `json:"currency"`
  175. BillingType string `json:"billing_type"`
  176. BillingPeriod string `json:"billing_period"`
  177. Status string `json:"status"`
  178. TaxMode string `json:"tax_mode"`
  179. TaxCategory string `json:"tax_category"`
  180. DefaultSuccessUrl *string `json:"default_success_url"`
  181. CreatedAt string `json:"created_at"`
  182. UpdatedAt string `json:"updated_at"`
  183. Mode string `json:"mode"`
  184. } `json:"product"`
  185. Units int `json:"units"`
  186. Customer struct {
  187. Id string `json:"id"`
  188. Object string `json:"object"`
  189. Email string `json:"email"`
  190. Name string `json:"name"`
  191. Country string `json:"country"`
  192. CreatedAt string `json:"created_at"`
  193. UpdatedAt string `json:"updated_at"`
  194. Mode string `json:"mode"`
  195. } `json:"customer"`
  196. Status string `json:"status"`
  197. Metadata map[string]string `json:"metadata"`
  198. Mode string `json:"mode"`
  199. } `json:"object"`
  200. }
  201. func CreemWebhook(c *gin.Context) {
  202. if !isCreemWebhookEnabled() {
  203. logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
  204. c.AbortWithStatus(http.StatusForbidden)
  205. return
  206. }
  207. // 读取body内容用于打印,同时保留原始数据供后续使用
  208. bodyBytes, err := io.ReadAll(c.Request.Body)
  209. if err != nil {
  210. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
  211. c.AbortWithStatus(http.StatusBadRequest)
  212. return
  213. }
  214. // 获取签名头
  215. signature := c.GetHeader(CreemSignatureHeader)
  216. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
  217. if signature == "" {
  218. logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少签名 path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
  219. c.AbortWithStatus(http.StatusUnauthorized)
  220. return
  221. }
  222. // 验证签名
  223. if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
  224. logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
  225. c.AbortWithStatus(http.StatusUnauthorized)
  226. return
  227. }
  228. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 验签成功 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
  229. // 重新设置body供后续的ShouldBindJSON使用
  230. c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
  231. // 解析新格式的webhook数据
  232. var webhookEvent CreemWebhookEvent
  233. if err := c.ShouldBindJSON(&webhookEvent); err != nil {
  234. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
  235. c.AbortWithStatus(http.StatusBadRequest)
  236. return
  237. }
  238. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
  239. // 根据事件类型处理不同的webhook
  240. switch webhookEvent.EventType {
  241. case "checkout.completed":
  242. handleCheckoutCompleted(c, &webhookEvent)
  243. default:
  244. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 忽略事件 event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
  245. c.Status(http.StatusOK)
  246. }
  247. }
  248. // 处理支付完成事件
  249. func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
  250. // 验证订单状态
  251. if event.Object.Order.Status != "paid" {
  252. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
  253. c.Status(http.StatusOK)
  254. return
  255. }
  256. // 获取引用ID(这是我们创建订单时传递的request_id)
  257. referenceId := event.Object.RequestId
  258. if referenceId == "" {
  259. logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少 request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
  260. c.AbortWithStatus(http.StatusBadRequest)
  261. return
  262. }
  263. // Try complete subscription order first
  264. LockOrder(referenceId)
  265. defer UnlockOrder(referenceId)
  266. if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event), model.PaymentProviderCreem, ""); err == nil {
  267. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
  268. c.Status(http.StatusOK)
  269. return
  270. } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
  271. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
  272. c.AbortWithStatus(http.StatusInternalServerError)
  273. return
  274. }
  275. // 验证订单类型,目前只处理一次性付款(充值)
  276. if event.Object.Order.Type != "onetime" {
  277. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
  278. c.Status(http.StatusOK)
  279. return
  280. }
  281. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
  282. // 查询本地订单确认存在
  283. topUp := model.GetTopUpByTradeNo(referenceId)
  284. if topUp == nil {
  285. logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 充值订单不存在 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
  286. c.AbortWithStatus(http.StatusBadRequest)
  287. return
  288. }
  289. if topUp.Status != common.TopUpStatusPending {
  290. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
  291. c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
  292. return
  293. }
  294. // 处理充值,传入客户邮箱和姓名信息
  295. customerEmail := event.Object.Customer.Email
  296. customerName := event.Object.Customer.Name
  297. // 防护性检查,确保邮箱和姓名不为空字符串
  298. if customerEmail == "" {
  299. logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
  300. }
  301. if customerName == "" {
  302. logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
  303. }
  304. err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
  305. if err != nil {
  306. logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
  307. c.AbortWithStatus(http.StatusInternalServerError)
  308. return
  309. }
  310. logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
  311. c.Status(http.StatusOK)
  312. }
  313. type CreemCheckoutRequest struct {
  314. ProductId string `json:"product_id"`
  315. RequestId string `json:"request_id"`
  316. Customer struct {
  317. Email string `json:"email"`
  318. } `json:"customer"`
  319. Metadata map[string]string `json:"metadata,omitempty"`
  320. }
  321. type CreemCheckoutResponse struct {
  322. CheckoutUrl string `json:"checkout_url"`
  323. Id string `json:"id"`
  324. }
  325. func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct, email string, username string) (string, error) {
  326. if setting.CreemApiKey == "" {
  327. return "", fmt.Errorf("未配置Creem API密钥")
  328. }
  329. // 根据测试模式选择 API 端点
  330. apiUrl := "https://api.creem.io/v1/checkouts"
  331. if setting.CreemTestMode {
  332. apiUrl = "https://test-api.creem.io/v1/checkouts"
  333. logger.LogInfo(ctx, fmt.Sprintf("Creem 使用测试环境 api_url=%s", apiUrl))
  334. }
  335. // 构建请求数据,确保包含用户邮箱
  336. requestData := CreemCheckoutRequest{
  337. ProductId: product.ProductId,
  338. RequestId: referenceId, // 这个作为订单ID传递给Creem
  339. Customer: struct {
  340. Email string `json:"email"`
  341. }{
  342. Email: email, // 用户邮箱会在支付页面预填充
  343. },
  344. Metadata: map[string]string{
  345. "username": username,
  346. "reference_id": referenceId,
  347. "product_name": product.Name,
  348. "quota": fmt.Sprintf("%d", product.Quota),
  349. },
  350. }
  351. // 序列化请求数据
  352. jsonData, err := json.Marshal(requestData)
  353. if err != nil {
  354. return "", fmt.Errorf("序列化请求数据失败: %v", err)
  355. }
  356. // 创建 HTTP 请求
  357. req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
  358. if err != nil {
  359. return "", fmt.Errorf("创建HTTP请求失败: %v", err)
  360. }
  361. // 设置请求头
  362. req.Header.Set("Content-Type", "application/json")
  363. req.Header.Set("x-api-key", setting.CreemApiKey)
  364. logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
  365. // 发送请求
  366. client := &http.Client{
  367. Timeout: 30 * time.Second,
  368. }
  369. resp, err := client.Do(req)
  370. if err != nil {
  371. return "", fmt.Errorf("发送HTTP请求失败: %v", err)
  372. }
  373. defer resp.Body.Close()
  374. // 读取响应
  375. body, err := io.ReadAll(resp.Body)
  376. if err != nil {
  377. return "", fmt.Errorf("读取响应失败: %v", err)
  378. }
  379. logger.LogInfo(ctx, fmt.Sprintf("Creem API 响应已收到 trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
  380. // 检查响应状态
  381. if resp.StatusCode/100 != 2 {
  382. return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode)
  383. }
  384. // 解析响应
  385. var checkoutResp CreemCheckoutResponse
  386. err = json.Unmarshal(body, &checkoutResp)
  387. if err != nil {
  388. return "", fmt.Errorf("解析响应失败: %v", err)
  389. }
  390. if checkoutResp.CheckoutUrl == "" {
  391. return "", fmt.Errorf("Creem API resp no checkout url ")
  392. }
  393. logger.LogInfo(ctx, fmt.Sprintf("Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
  394. return checkoutResp.CheckoutUrl, nil
  395. }