From 99b9a34e19b6a137c69536ef7671e00088a10607 Mon Sep 17 00:00:00 2001 From: Little Write Date: Mon, 8 Sep 2025 23:07:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20=E5=90=8E=E7=AB=AF=20?= =?UTF-8?q?=E9=83=A8=E5=88=86=EF=BC=8Cwebo=20hhok=20=E5=BE=85=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/misc.go | 2 + controller/topup_creem.go | 256 ++++++++++++++++++++++++++++++++++++++ model/option.go | 9 ++ model/topup.go | 49 ++++++++ router/api-router.go | 2 + setting/payment_creem.go | 5 + 6 files changed, 323 insertions(+) create mode 100644 controller/topup_creem.go create mode 100644 setting/payment_creem.go diff --git a/controller/misc.go b/controller/misc.go index a3ed9be9a..8411a281f 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -74,6 +74,8 @@ func GetStatus(c *gin.Context) { "default_collapse_sidebar": common.DefaultCollapseSidebar, "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", + "creem_products": setting.CreemProducts, "mj_notify_enabled": setting.MjNotifyEnabled, "chats": setting.Chats, "demo_site_enabled": operation_setting.DemoSiteEnabled, diff --git a/controller/topup_creem.go b/controller/topup_creem.go new file mode 100644 index 000000000..c02a86994 --- /dev/null +++ b/controller/topup_creem.go @@ -0,0 +1,256 @@ +package controller + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "one-api/common" + "one-api/model" + "one-api/setting" + "time" + + "github.com/gin-gonic/gin" + "github.com/thanhpk/randstr" +) + +const ( + PaymentMethodCreem = "creem" +) + +var creemAdaptor = &CreemAdaptor{} + +type CreemPayRequest struct { + ProductId string `json:"product_id"` + PaymentMethod string `json:"payment_method"` +} + +type CreemProduct struct { + ProductId string `json:"productId"` + Name string `json:"name"` + Price float64 `json:"price"` + Currency string `json:"currency"` + Quota int64 `json:"quota"` +} + +type CreemAdaptor struct { +} + +func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { + if req.PaymentMethod != PaymentMethodCreem { + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + return + } + + if req.ProductId == "" { + c.JSON(200, gin.H{"message": "error", "data": "请选择产品"}) + return + } + + // 解析产品列表 + var products []CreemProduct + err := json.Unmarshal([]byte(setting.CreemProducts), &products) + if err != nil { + log.Println("解析Creem产品列表失败", err) + c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"}) + return + } + + // 查找对应的产品 + var selectedProduct *CreemProduct + for _, product := range products { + if product.ProductId == req.ProductId { + selectedProduct = &product + break + } + } + + if selectedProduct == nil { + c.JSON(200, gin.H{"message": "error", "data": "产品不存在"}) + return + } + + id := c.GetInt("id") + user, _ := model.GetUserById(id, false) + + reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) + referenceId := "ref_" + common.Sha1([]byte(reference)) + + checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username) + if err != nil { + log.Println("获取Creem支付链接失败", err) + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + topUp := &model.TopUp{ + UserId: id, + Amount: selectedProduct.Quota, + Money: selectedProduct.Price, + TradeNo: referenceId, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + err = topUp.Insert() + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "checkout_url": checkoutUrl, + }, + }) +} + +func RequestCreemPay(c *gin.Context) { + var req CreemPayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + creemAdaptor.RequestPay(c, &req) +} + +type CreemWebhookData struct { + Type string `json:"type"` + Data struct { + RequestId string `json:"request_id"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata"` + } `json:"data"` +} + +func CreemWebhook(c *gin.Context) { + // 解析 webhook 数据 + var webhookData CreemWebhookData + if err := c.ShouldBindJSON(&webhookData); err != nil { + log.Printf("解析Creem Webhook参数失败: %v\n", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + // 检查事件类型 + if webhookData.Type != "checkout.completed" { + log.Printf("忽略Creem Webhook事件类型: %s", webhookData.Type) + c.Status(http.StatusOK) + return + } + + // 获取引用ID + referenceId := webhookData.Data.RequestId + if referenceId == "" { + log.Println("Creem Webhook缺少request_id字段") + c.AbortWithStatus(http.StatusBadRequest) + return + } + + // 处理支付完成事件 + err := model.RechargeCreem(referenceId) + if err != nil { + log.Println("Creem充值处理失败:", err.Error(), referenceId) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + log.Printf("Creem充值成功: %s", referenceId) + c.Status(http.StatusOK) +} + +type CreemCheckoutRequest struct { + ProductId string `json:"product_id"` + RequestId string `json:"request_id"` + Customer struct { + Email string `json:"email"` + } `json:"customer"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type CreemCheckoutResponse struct { + CheckoutUrl string `json:"checkout_url"` + Id string `json:"id"` +} + +func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) { + if setting.CreemApiKey == "" { + return "", fmt.Errorf("未配置Creem API密钥") + } + + // 根据测试模式选择 API 端点 + apiUrl := "https://api.creem.io/v1/checkouts" + if setting.CreemTestMode { + apiUrl = "https://test-api.creem.io/v1/checkouts" + } + + // 构建请求数据 + requestData := CreemCheckoutRequest{ + ProductId: product.ProductId, + RequestId: referenceId, + Customer: struct { + Email string `json:"email"` + }{ + Email: email, + }, + Metadata: map[string]string{ + "username": username, + "reference_id": referenceId, + }, + } + + // 序列化请求数据 + jsonData, err := json.Marshal(requestData) + if err != nil { + return "", fmt.Errorf("序列化请求数据失败: %v", err) + } + + // 创建 HTTP 请求 + req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("创建HTTP请求失败: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", setting.CreemApiKey) + + // 发送请求 + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送HTTP请求失败: %v", err) + } + defer resp.Body.Close() + log.Printf(" creem req host: %s, key %s req 【%s】", apiUrl, setting.CreemApiKey, jsonData) + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %v", err) + } + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Creem API 返回错误状态 %d: %s", resp.StatusCode, string(body)) + } + + // 解析响应 + var checkoutResp CreemCheckoutResponse + err = json.Unmarshal(body, &checkoutResp) + if err != nil { + return "", fmt.Errorf("解析响应失败: %v", err) + } + + if checkoutResp.CheckoutUrl == "" { + return "", fmt.Errorf("Creem API 未返回支付链接") + } + + log.Printf("Creem 支付链接创建成功: %s, 订单ID: %s", referenceId, checkoutResp.Id) + return checkoutResp.CheckoutUrl, nil +} diff --git a/model/option.go b/model/option.go index 05b99b41a..8577c78f8 100644 --- a/model/option.go +++ b/model/option.go @@ -81,6 +81,9 @@ func InitOptionMap() { common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret common.OptionMap["StripePriceId"] = setting.StripePriceId common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64) + common.OptionMap["CreemApiKey"] = setting.CreemApiKey + common.OptionMap["CreemProducts"] = setting.CreemProducts + common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -326,6 +329,12 @@ func updateOptionMap(key string, value string) (err error) { setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64) case "StripeMinTopUp": setting.StripeMinTopUp, _ = strconv.Atoi(value) + case "CreemApiKey": + setting.CreemApiKey = value + case "CreemProducts": + setting.CreemProducts = value + case "CreemTestMode": + setting.CreemTestMode = value == "true" case "TopupGroupRatio": err = common.UpdateTopupGroupRatioByJSONString(value) case "GitHubClientId": diff --git a/model/topup.go b/model/topup.go index c34c0ce62..a208ecae7 100644 --- a/model/topup.go +++ b/model/topup.go @@ -98,3 +98,52 @@ func Recharge(referenceId string, customerId string) (err error) { return nil } + +func RechargeCreem(referenceId string) (err error) { + if referenceId == "" { + return errors.New("未提供支付单号") + } + + var quota float64 + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + err = tx.Save(topUp).Error + if err != nil { + return err + } + + // Creem 直接使用 Amount 作为充值额度 + quota = float64(topUp.Amount) + err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quota)).Error + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return errors.New("充值失败," + err.Error()) + } + + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", common.FormatQuota(int(quota)), topUp.Money)) + + return nil +} diff --git a/router/api-router.go b/router/api-router.go index bc49803a2..49690dc00 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -39,6 +39,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + apiRouter.POST("/creem/webhook", controller.CreemWebhook) userRoute := apiRouter.Group("/user") { @@ -64,6 +65,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/amount", controller.RequestAmount) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) + selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) } diff --git a/setting/payment_creem.go b/setting/payment_creem.go new file mode 100644 index 000000000..8aa6e7de4 --- /dev/null +++ b/setting/payment_creem.go @@ -0,0 +1,5 @@ +package setting + +var CreemApiKey = "" +var CreemProducts = "[]" +var CreemTestMode = false