Просмотр исходного кода

完成 后端 部分,webo hhok 待完善

Little Write 6 месяцев назад
Родитель
Сommit
99b9a34e19
6 измененных файлов с 323 добавлено и 0 удалено
  1. 2 0
      controller/misc.go
  2. 256 0
      controller/topup_creem.go
  3. 9 0
      model/option.go
  4. 49 0
      model/topup.go
  5. 2 0
      router/api-router.go
  6. 5 0
      setting/payment_creem.go

+ 2 - 0
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,

+ 256 - 0
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
+}

+ 9 - 0
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":

+ 49 - 0
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
+}

+ 2 - 0
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)
 			}

+ 5 - 0
setting/payment_creem.go

@@ -0,0 +1,5 @@
+package setting
+
+var CreemApiKey = ""
+var CreemProducts = "[]"
+var CreemTestMode = false