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

Merge pull request #1823 from littlewrite/feat_subscribe_sp1

新增 creem 支付
Seefs 4 месяцев назад
Родитель
Сommit
36c603f3b2

+ 2 - 0
controller/topup.go

@@ -51,6 +51,8 @@ func GetTopUpInfo(c *gin.Context) {
 	data := gin.H{
 		"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
 		"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
+		"enable_creem_topup":  setting.CreemApiKey != "" && setting.CreemProducts != "[]",
+		"creem_products":      setting.CreemProducts,
 		"pay_methods":         payMethods,
 		"min_topup":           operation_setting.MinTopUp,
 		"stripe_min_topup":    setting.StripeMinTopUp,

+ 461 - 0
controller/topup_creem.go

@@ -0,0 +1,461 @@
+package controller
+
+import (
+	"bytes"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"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"
+	CreemSignatureHeader = "creem-signature"
+)
+
+var creemAdaptor = &CreemAdaptor{}
+
+// 生成HMAC-SHA256签名
+func generateCreemSignature(payload string, secret string) string {
+	h := hmac.New(sha256.New, []byte(secret))
+	h.Write([]byte(payload))
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+// 验证Creem webhook签名
+func verifyCreemSignature(payload string, signature string, secret string) bool {
+	if secret == "" {
+		log.Printf("Creem webhook secret not set")
+		if setting.CreemTestMode {
+			log.Printf("Skip Creem webhook sign verify in test mode")
+			return true
+		}
+		return false
+	}
+
+	expectedSignature := generateCreemSignature(payload, secret)
+	return hmac.Equal([]byte(signature), []byte(expectedSignature))
+}
+
+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)
+
+	// 生成唯一的订单引用ID
+	reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
+	referenceId := "ref_" + common.Sha1([]byte(reference))
+
+	// 先创建订单记录,使用产品配置的金额和充值额度
+	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 {
+		log.Printf("创建Creem订单失败: %v", err)
+		c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
+		return
+	}
+
+	// 创建支付链接,传入用户邮箱
+	checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
+	if err != nil {
+		log.Printf("获取Creem支付链接失败: %v", err)
+		c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
+		return
+	}
+
+	log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
+		id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
+
+	c.JSON(200, gin.H{
+		"message": "success",
+		"data": gin.H{
+			"checkout_url": checkoutUrl,
+			"order_id":     referenceId,
+		},
+	})
+}
+
+func RequestCreemPay(c *gin.Context) {
+	var req CreemPayRequest
+
+	// 读取body内容用于打印,同时保留原始数据供后续使用
+	bodyBytes, err := io.ReadAll(c.Request.Body)
+	if err != nil {
+		log.Printf("read creem pay req body err: %v", err)
+		c.JSON(200, gin.H{"message": "error", "data": "read query error"})
+		return
+	}
+
+	// 打印body内容
+	log.Printf("creem pay request body: %s", string(bodyBytes))
+
+	// 重新设置body供后续的ShouldBindJSON使用
+	c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
+
+	err = c.ShouldBindJSON(&req)
+	if err != nil {
+		c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
+		return
+	}
+	creemAdaptor.RequestPay(c, &req)
+}
+
+// 新的Creem Webhook结构体,匹配实际的webhook数据格式
+type CreemWebhookEvent struct {
+	Id        string `json:"id"`
+	EventType string `json:"eventType"`
+	CreatedAt int64  `json:"created_at"`
+	Object    struct {
+		Id        string `json:"id"`
+		Object    string `json:"object"`
+		RequestId string `json:"request_id"`
+		Order     struct {
+			Object      string `json:"object"`
+			Id          string `json:"id"`
+			Customer    string `json:"customer"`
+			Product     string `json:"product"`
+			Amount      int    `json:"amount"`
+			Currency    string `json:"currency"`
+			SubTotal    int    `json:"sub_total"`
+			TaxAmount   int    `json:"tax_amount"`
+			AmountDue   int    `json:"amount_due"`
+			AmountPaid  int    `json:"amount_paid"`
+			Status      string `json:"status"`
+			Type        string `json:"type"`
+			Transaction string `json:"transaction"`
+			CreatedAt   string `json:"created_at"`
+			UpdatedAt   string `json:"updated_at"`
+			Mode        string `json:"mode"`
+		} `json:"order"`
+		Product struct {
+			Id                string  `json:"id"`
+			Object            string  `json:"object"`
+			Name              string  `json:"name"`
+			Description       string  `json:"description"`
+			Price             int     `json:"price"`
+			Currency          string  `json:"currency"`
+			BillingType       string  `json:"billing_type"`
+			BillingPeriod     string  `json:"billing_period"`
+			Status            string  `json:"status"`
+			TaxMode           string  `json:"tax_mode"`
+			TaxCategory       string  `json:"tax_category"`
+			DefaultSuccessUrl *string `json:"default_success_url"`
+			CreatedAt         string  `json:"created_at"`
+			UpdatedAt         string  `json:"updated_at"`
+			Mode              string  `json:"mode"`
+		} `json:"product"`
+		Units    int `json:"units"`
+		Customer struct {
+			Id        string `json:"id"`
+			Object    string `json:"object"`
+			Email     string `json:"email"`
+			Name      string `json:"name"`
+			Country   string `json:"country"`
+			CreatedAt string `json:"created_at"`
+			UpdatedAt string `json:"updated_at"`
+			Mode      string `json:"mode"`
+		} `json:"customer"`
+		Status   string            `json:"status"`
+		Metadata map[string]string `json:"metadata"`
+		Mode     string            `json:"mode"`
+	} `json:"object"`
+}
+
+// 保留旧的结构体作为兼容
+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) {
+	// 读取body内容用于打印,同时保留原始数据供后续使用
+	bodyBytes, err := io.ReadAll(c.Request.Body)
+	if err != nil {
+		log.Printf("读取Creem Webhook请求body失败: %v", err)
+		c.AbortWithStatus(http.StatusBadRequest)
+		return
+	}
+
+	// 获取签名头
+	signature := c.GetHeader(CreemSignatureHeader)
+
+	// 打印关键信息(避免输出完整敏感payload)
+	log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
+	if setting.CreemTestMode {
+		log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
+	} else if signature == "" {
+		log.Printf("Creem Webhook缺少签名头")
+		c.AbortWithStatus(http.StatusUnauthorized)
+		return
+	}
+
+	// 验证签名
+	if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
+		log.Printf("Creem Webhook签名验证失败")
+		c.AbortWithStatus(http.StatusUnauthorized)
+		return
+	}
+
+	log.Printf("Creem Webhook签名验证成功")
+
+	// 重新设置body供后续的ShouldBindJSON使用
+	c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
+
+	// 解析新格式的webhook数据
+	var webhookEvent CreemWebhookEvent
+	if err := c.ShouldBindJSON(&webhookEvent); err != nil {
+		log.Printf("解析Creem Webhook参数失败: %v", err)
+		c.AbortWithStatus(http.StatusBadRequest)
+		return
+	}
+
+	log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
+
+	// 根据事件类型处理不同的webhook
+	switch webhookEvent.EventType {
+	case "checkout.completed":
+		handleCheckoutCompleted(c, &webhookEvent)
+	default:
+		log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
+		c.Status(http.StatusOK)
+	}
+}
+
+// 处理支付完成事件
+func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
+	// 验证订单状态
+	if event.Object.Order.Status != "paid" {
+		log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
+		c.Status(http.StatusOK)
+		return
+	}
+
+	// 获取引用ID(这是我们创建订单时传递的request_id)
+	referenceId := event.Object.RequestId
+	if referenceId == "" {
+		log.Println("Creem Webhook缺少request_id字段")
+		c.AbortWithStatus(http.StatusBadRequest)
+		return
+	}
+
+	// 验证订单类型,目前只处理一次性付款
+	if event.Object.Order.Type != "onetime" {
+		log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
+		c.Status(http.StatusOK)
+		return
+	}
+
+	// 记录详细的支付信息
+	log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
+		referenceId,
+		event.Object.Order.Id,
+		event.Object.Order.AmountPaid,
+		event.Object.Order.Currency,
+		event.Object.Product.Name)
+
+	// 查询本地订单确认存在
+	topUp := model.GetTopUpByTradeNo(referenceId)
+	if topUp == nil {
+		log.Printf("Creem充值订单不存在: %s", referenceId)
+		c.AbortWithStatus(http.StatusBadRequest)
+		return
+	}
+
+	if topUp.Status != common.TopUpStatusPending {
+		log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
+		c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
+		return
+	}
+
+	// 处理充值,传入客户邮箱和姓名信息
+	customerEmail := event.Object.Customer.Email
+	customerName := event.Object.Customer.Name
+
+	// 防护性检查,确保邮箱和姓名不为空字符串
+	if customerEmail == "" {
+		log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
+	}
+	if customerName == "" {
+		log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
+	}
+
+	err := model.RechargeCreem(referenceId, customerEmail, customerName)
+	if err != nil {
+		log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
+		c.AbortWithStatus(http.StatusInternalServerError)
+		return
+	}
+
+	log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
+		referenceId, topUp.Amount, topUp.Money)
+	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"
+		log.Printf("使用Creem测试环境: %s", apiUrl)
+	}
+
+	// 构建请求数据,确保包含用户邮箱
+	requestData := CreemCheckoutRequest{
+		ProductId: product.ProductId,
+		RequestId: referenceId, // 这个作为订单ID传递给Creem
+		Customer: struct {
+			Email string `json:"email"`
+		}{
+			Email: email, // 用户邮箱会在支付页面预填充
+		},
+		Metadata: map[string]string{
+			"username":     username,
+			"reference_id": referenceId,
+			"product_name": product.Name,
+			"quota":        fmt.Sprintf("%d", product.Quota),
+		},
+	}
+
+	// 序列化请求数据
+	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)
+
+	log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
+		apiUrl, product.ProductId, email, referenceId)
+
+	// 发送请求
+	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()
+
+	// 读取响应
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("读取响应失败: %v", err)
+	}
+
+	log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
+
+	// 检查响应状态
+	if resp.StatusCode/100 != 2 {
+		return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode)
+	}
+	// 解析响应
+	var checkoutResp CreemCheckoutResponse
+	err = json.Unmarshal(body, &checkoutResp)
+	if err != nil {
+		return "", fmt.Errorf("解析响应失败: %v", err)
+	}
+
+	if checkoutResp.CheckoutUrl == "" {
+		return "", fmt.Errorf("Creem API resp no checkout url ")
+	}
+
+	log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
+	return checkoutResp.CheckoutUrl, nil
+}

+ 12 - 0
model/option.go

@@ -84,6 +84,10 @@ func InitOptionMap() {
 	common.OptionMap["StripePriceId"] = setting.StripePriceId
 	common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
 	common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
+	common.OptionMap["CreemApiKey"] = setting.CreemApiKey
+	common.OptionMap["CreemProducts"] = setting.CreemProducts
+	common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode)
+	common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret
 	common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
 	common.OptionMap["Chats"] = setting.Chats2JsonString()
 	common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -342,6 +346,14 @@ func updateOptionMap(key string, value string) (err error) {
 		setting.StripeMinTopUp, _ = strconv.Atoi(value)
 	case "StripePromotionCodesEnabled":
 		setting.StripePromotionCodesEnabled = value == "true"
+	case "CreemApiKey":
+		setting.CreemApiKey = value
+	case "CreemProducts":
+		setting.CreemProducts = value
+	case "CreemTestMode":
+		setting.CreemTestMode = value == "true"
+	case "CreemWebhookSecret":
+		setting.CreemWebhookSecret = value
 	case "TopupGroupRatio":
 		err = common.UpdateTopupGroupRatioByJSONString(value)
 	case "GitHubClientId":

+ 69 - 0
model/topup.go

@@ -305,3 +305,72 @@ func ManualCompleteTopUp(tradeNo string) error {
 	RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
 	return nil
 }
+func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
+	if referenceId == "" {
+		return errors.New("未提供支付单号")
+	}
+
+	var quota int64
+	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 = topUp.Amount
+
+		// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
+		updateFields := map[string]interface{}{
+			"quota": gorm.Expr("quota + ?", quota),
+		}
+
+		// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
+		if customerEmail != "" {
+			// 先检查用户当前邮箱是否为空
+			var user User
+			err = tx.Where("id = ?", topUp.UserId).First(&user).Error
+			if err != nil {
+				return err
+			}
+
+			// 如果用户邮箱为空,则更新为支付时使用的邮箱
+			if user.Email == "" {
+				updateFields["email"] = customerEmail
+			}
+		}
+
+		err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).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", quota, topUp.Money))
+
+	return nil
+}

+ 2 - 0
router/api-router.go

@@ -41,6 +41,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)
 
 		// Universal secure verification routes
 		apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
@@ -81,6 +82,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)
 

+ 6 - 0
setting/payment_creem.go

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

+ 4 - 0
web/src/components/settings/PaymentSetting.jsx

@@ -22,6 +22,7 @@ import { Card, Spin } from '@douyinfe/semi-ui';
 import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
 import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
 import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
+import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
 import { API, showError, toBoolean } from '../../helpers';
 import { useTranslation } from 'react-i18next';
 
@@ -142,6 +143,9 @@ const PaymentSetting = () => {
         <Card style={{ marginTop: '10px' }}>
           <SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
         </Card>
+        <Card style={{ marginTop: '10px' }}>
+          <SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
+        </Card>
       </Spin>
     </>
   );

+ 31 - 1
web/src/components/topup/RechargeCard.jsx

@@ -52,6 +52,9 @@ const RechargeCard = ({
   t,
   enableOnlineTopUp,
   enableStripeTopUp,
+  enableCreemTopUp,
+  creemProducts,
+  creemPreTopUp,
   presetAmounts,
   selectedPreset,
   selectPresetAmount,
@@ -84,6 +87,7 @@ const RechargeCard = ({
   const onlineFormApiRef = useRef(null);
   const redeemFormApiRef = useRef(null);
   const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
+  console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
   return (
     <Card className='!rounded-2xl shadow-sm border-0'>
       {/* 卡片头部 */}
@@ -216,7 +220,7 @@ const RechargeCard = ({
             <div className='py-8 flex justify-center'>
               <Spin size='large' />
             </div>
-          ) : enableOnlineTopUp || enableStripeTopUp ? (
+          ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
             <Form
               getFormApi={(api) => (onlineFormApiRef.current = api)}
               initValues={{ topUpCount: topUpCount }}
@@ -480,6 +484,32 @@ const RechargeCard = ({
                     </div>
                   </Form.Slot>
                 )}
+
+                {/* Creem 充值区域 */}
+                {enableCreemTopUp && creemProducts.length > 0 && (
+                  <Form.Slot label={t('Creem 充值')}>
+                    <div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3'>
+                      {creemProducts.map((product, index) => (
+                        <Card
+                          key={index}
+                          onClick={() => creemPreTopUp(product)}
+                          className='cursor-pointer !rounded-2xl transition-all hover:shadow-md border-gray-200 hover:border-gray-300'
+                          bodyStyle={{ textAlign: 'center', padding: '16px' }}
+                        >
+                          <div className='font-medium text-lg mb-2'>
+                            {product.name}
+                          </div>
+                          <div className='text-sm text-gray-600 mb-2'>
+                            {t('充值额度')}: {product.quota}
+                          </div>
+                          <div className='text-lg font-semibold text-blue-600'>
+                            {product.currency === 'EUR' ? '€' : '$'}{product.price}
+                          </div>
+                        </Card>
+                      ))}
+                    </div>
+                  </Form.Slot>
+                )}
               </div>
             </Form>
           ) : (

+ 102 - 0
web/src/components/topup/index.jsx

@@ -63,6 +63,12 @@ const TopUp = () => {
   );
   const [statusLoading, setStatusLoading] = useState(true);
 
+  // Creem 相关状态
+  const [creemProducts, setCreemProducts] = useState([]);
+  const [enableCreemTopUp, setEnableCreemTopUp] = useState(false);
+  const [creemOpen, setCreemOpen] = useState(false);
+  const [selectedCreemProduct, setSelectedCreemProduct] = useState(null);
+
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [open, setOpen] = useState(false);
   const [payWay, setPayWay] = useState('');
@@ -248,6 +254,55 @@ const TopUp = () => {
     }
   };
 
+  const creemPreTopUp = async (product) => {
+    if (!enableCreemTopUp) {
+      showError(t('管理员未开启 Creem 充值!'));
+      return;
+    }
+    setSelectedCreemProduct(product);
+    setCreemOpen(true);
+  };
+
+  const onlineCreemTopUp = async () => {
+    if (!selectedCreemProduct) {
+      showError(t('请选择产品'));
+      return;
+    }
+    // Validate product has required fields
+    if (!selectedCreemProduct.productId) {
+      showError(t('产品配置错误,请联系管理员'));
+      return;
+    }
+    setConfirmLoading(true);
+    try {
+      const res = await API.post('/api/user/creem/pay', {
+        product_id: selectedCreemProduct.productId,
+        payment_method: 'creem',
+      });
+      if (res !== undefined) {
+        const { message, data } = res.data;
+        if (message === 'success') {
+          processCreemCallback(data);
+        } else {
+          showError(data);
+        }
+      } else {
+        showError(res);
+      }
+    } catch (err) {
+      console.log(err);
+      showError(t('支付请求失败'));
+    } finally {
+      setCreemOpen(false);
+      setConfirmLoading(false);
+    }
+  };
+
+  const processCreemCallback = (data) => {
+    // 与 Stripe 保持一致的实现方式
+    window.open(data.checkout_url, '_blank');
+  };
+
   const getUserQuota = async () => {
     let res = await API.get(`/api/user/self`);
     const { success, message, data } = res.data;
@@ -322,6 +377,7 @@ const TopUp = () => {
           setPayMethods(payMethods);
           const enableStripeTopUp = data.enable_stripe_topup || false;
           const enableOnlineTopUp = data.enable_online_topup || false;
+          const enableCreemTopUp = data.enable_creem_topup || false;
           const minTopUpValue = enableOnlineTopUp
             ? data.min_topup
             : enableStripeTopUp
@@ -329,9 +385,20 @@ const TopUp = () => {
               : 1;
           setEnableOnlineTopUp(enableOnlineTopUp);
           setEnableStripeTopUp(enableStripeTopUp);
+          setEnableCreemTopUp(enableCreemTopUp);
           setMinTopUp(minTopUpValue);
           setTopUpCount(minTopUpValue);
 
+          // 设置 Creem 产品
+          try {
+            console.log(' data is ?', data);
+            console.log(' creem products is ?', data.creem_products);
+            const products = JSON.parse(data.creem_products || '[]');
+            setCreemProducts(products);
+          } catch (e) {
+            setCreemProducts([]);
+          }
+
           // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
           if (topupInfo.amount_options.length === 0) {
             setPresetAmounts(generatePresetAmounts(minTopUpValue));
@@ -500,6 +567,11 @@ const TopUp = () => {
     setOpenHistory(false);
   };
 
+  const handleCreemCancel = () => {
+    setCreemOpen(false);
+    setSelectedCreemProduct(null);
+  };
+
   // 选择预设充值额度
   const selectPresetAmount = (preset) => {
     setTopUpCount(preset.value);
@@ -563,6 +635,33 @@ const TopUp = () => {
         t={t}
       />
 
+      {/* Creem 充值确认模态框 */}
+      <Modal
+        title={t('确定要充值 $')}
+        visible={creemOpen}
+        onOk={onlineCreemTopUp}
+        onCancel={handleCreemCancel}
+        maskClosable={false}
+        size='small'
+        centered
+        confirmLoading={confirmLoading}
+      >
+        {selectedCreemProduct && (
+          <>
+            <p>
+              {t('产品名称')}:{selectedCreemProduct.name}
+            </p>
+            <p>
+              {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
+            </p>
+            <p>
+              {t('充值额度')}:{selectedCreemProduct.quota}
+            </p>
+            <p>{t('是否确认充值?')}</p>
+          </>
+        )}
+      </Modal>
+
       {/* 用户信息头部 */}
       <div className='space-y-6'>
         <div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
@@ -572,6 +671,9 @@ const TopUp = () => {
               t={t}
               enableOnlineTopUp={enableOnlineTopUp}
               enableStripeTopUp={enableStripeTopUp}
+              enableCreemTopUp={enableCreemTopUp}
+              creemProducts={creemProducts}
+              creemPreTopUp={creemPreTopUp}
               presetAmounts={presetAmounts}
               selectedPreset={selectedPreset}
               selectPresetAmount={selectPresetAmount}

+ 31 - 2
web/src/i18n/locales/en.json

@@ -2071,6 +2071,35 @@
     "默认区域,如: us-central1": "Default region, e.g.: us-central1",
     "默认折叠侧边栏": "Default collapse sidebar",
     "默认测试模型": "Default Test Model",
-    "默认补全倍率": "Default completion ratio"
+    "默认补全倍率": "Default completion ratio",
+    "选择充值套餐": "Choose a top-up package",
+    "Creem 设置": "Creem Setting",
+    "Creem 充值": "Creem Recharge",
+    "Creem 介绍": "Creem is the payment partner you always deserved, we strive for simplicity and straightforwardness on our APIs.",
+    "Creem Setting Tips": "Creem only supports preset fixed-amount products. These products and their prices need to be created and configured in advance on the Creem website, so custom dynamic amount top-ups are not supported. Configure the product name and price on Creem, obtain the Product Id, and then fill it in for the product below. Set the top-up amount and display price for this product in the new API.",
+    "Webhook 密钥": "Webhook Secret",
+    "测试模式": "Test Mode",
+    "Creem API 密钥,敏感信息不显示": "Creem API key, sensitive information not displayed",
+    "用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示": "The key used to validate webhook requests for the callback new-api, sensitive information is not displayed.",
+    "启用后将使用 Creem Test Mode": "",
+    "展示价格": "Display Pricing",
+    "Recharge Quota": "Recharge Quota",
+    "产品配置": "Product Configuration",
+    "产品名称": "Product Name",
+    "产品ID": "Product ID",
+    "暂无产品配置": "No product configuration",
+    "更新 Creem 设置": "Update Creem Settings",
+    "编辑产品": "Edit Product",
+    "添加产品": "Add Product",
+    "例如:基础套餐": "e.g.: Basic Package",
+    "例如:prod_6I8rBerHpPxyoiU9WK4kot": "e.g.: prod_6I8rBerHpPxyoiU9WK4kot",
+    "货币": "Currency",
+    "欧元": "EUR",
+    "USD (美元)": "USD (US Dollar)",
+    "EUR (欧元)": "EUR (Euro)",
+    "例如:4.99": "e.g.: 4.99",
+    "例如:100000": "e.g.: 100000",
+    "请填写完整的产品信息": "Please fill in complete product information",
+    "产品ID已存在": "Product ID already exists"
   }
-}
+}

+ 4 - 2
web/src/i18n/locales/zh.json

@@ -2062,6 +2062,8 @@
     "默认区域,如: us-central1": "默认区域,如: us-central1",
     "默认折叠侧边栏": "默认折叠侧边栏",
     "默认测试模型": "默认测试模型",
-    "默认补全倍率": "默认补全倍率"
+    "默认补全倍率": "默认补全倍率",
+    "Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。",
+    "Creem Setting Tips": "Creem 只支持预设的固定金额产品,这产品以及价格需要提前在Creem网站内创建配置,所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格,获取Product Id 后填到下面的产品,在new-api为该产品设置充值额度,以及展示价格。"
   }
-}
+}

+ 385 - 0
web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js

@@ -0,0 +1,385 @@
+import React, { useEffect, useState, useRef } from 'react';
+import {
+    Banner,
+    Button,
+    Form,
+    Row,
+    Col,
+    Typography,
+    Spin,
+    Table,
+    Modal,
+    Input,
+    InputNumber,
+    Select,
+} from '@douyinfe/semi-ui';
+const { Text } = Typography;
+import {
+    API,
+    showError,
+    showSuccess,
+} from '../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { Plus, Trash2 } from 'lucide-react';
+
+export default function SettingsPaymentGatewayCreem(props) {
+    const { t } = useTranslation();
+    const [loading, setLoading] = useState(false);
+    const [inputs, setInputs] = useState({
+        CreemApiKey: '',
+        CreemWebhookSecret: '',
+        CreemProducts: '[]',
+        CreemTestMode: false,
+    });
+    const [originInputs, setOriginInputs] = useState({});
+    const [products, setProducts] = useState([]);
+    const [showProductModal, setShowProductModal] = useState(false);
+    const [editingProduct, setEditingProduct] = useState(null);
+    const [productForm, setProductForm] = useState({
+        name: '',
+        productId: '',
+        price: 0,
+        quota: 0,
+        currency: 'USD',
+    });
+    const formApiRef = useRef(null);
+
+    useEffect(() => {
+        if (props.options && formApiRef.current) {
+            const currentInputs = {
+                CreemApiKey: props.options.CreemApiKey || '',
+                CreemWebhookSecret: props.options.CreemWebhookSecret || '',
+                CreemProducts: props.options.CreemProducts || '[]',
+                CreemTestMode: props.options.CreemTestMode === 'true',
+            };
+            setInputs(currentInputs);
+            setOriginInputs({ ...currentInputs });
+            formApiRef.current.setValues(currentInputs);
+
+            // Parse products
+            try {
+                const parsedProducts = JSON.parse(currentInputs.CreemProducts);
+                setProducts(parsedProducts);
+            } catch (e) {
+                setProducts([]);
+            }
+        }
+    }, [props.options]);
+
+    const handleFormChange = (values) => {
+        setInputs(values);
+    };
+
+    const submitCreemSetting = async () => {
+        setLoading(true);
+        try {
+            const options = [];
+
+            if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
+                options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
+            }
+
+            if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
+                options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
+            }
+
+            // Save test mode setting
+            options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
+
+            // Save products as JSON string
+            options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
+
+            // 发送请求
+            const requestQueue = options.map(opt =>
+                API.put('/api/option/', {
+                    key: opt.key,
+                    value: opt.value,
+                })
+            );
+
+            const results = await Promise.all(requestQueue);
+
+            // 检查所有请求是否成功
+            const errorResults = results.filter(res => !res.data.success);
+            if (errorResults.length > 0) {
+                errorResults.forEach(res => {
+                    showError(res.data.message);
+                });
+            } else {
+                showSuccess(t('更新成功'));
+                // 更新本地存储的原始值
+                setOriginInputs({ ...inputs });
+                props.refresh?.();
+            }
+        } catch (error) {
+            showError(t('更新失败'));
+        }
+        setLoading(false);
+    };
+
+    const openProductModal = (product = null) => {
+        if (product) {
+            setEditingProduct(product);
+            setProductForm({ ...product });
+        } else {
+            setEditingProduct(null);
+            setProductForm({
+                name: '',
+                productId: '',
+                price: 0,
+                quota: 0,
+                currency: 'USD',
+            });
+        }
+        setShowProductModal(true);
+    };
+
+    const closeProductModal = () => {
+        setShowProductModal(false);
+        setEditingProduct(null);
+        setProductForm({
+            name: '',
+            productId: '',
+            price: 0,
+            quota: 0,
+            currency: 'USD',
+        });
+    };
+
+    const saveProduct = () => {
+        if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) {
+            showError(t('请填写完整的产品信息'));
+            return;
+        }
+
+        let newProducts = [...products];
+        if (editingProduct) {
+            // 编辑现有产品
+            const index = newProducts.findIndex(p => p.productId === editingProduct.productId);
+            if (index !== -1) {
+                newProducts[index] = { ...productForm };
+            }
+        } else {
+            // 添加新产品
+            if (newProducts.find(p => p.productId === productForm.productId)) {
+                showError(t('产品ID已存在'));
+                return;
+            }
+            newProducts.push({ ...productForm });
+        }
+
+        setProducts(newProducts);
+        closeProductModal();
+    };
+
+    const deleteProduct = (productId) => {
+        const newProducts = products.filter(p => p.productId !== productId);
+        setProducts(newProducts);
+    };
+
+    const columns = [
+        {
+            title: t('产品名称'),
+            dataIndex: 'name',
+            key: 'name',
+        },
+        {
+            title: t('产品ID'),
+            dataIndex: 'productId',
+            key: 'productId',
+        },
+        {
+            title: t('展示价格'),
+            dataIndex: 'price',
+            key: 'price',
+            render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`,
+        },
+        {
+            title: t('充值额度'),
+            dataIndex: 'quota',
+            key: 'quota',
+        },
+        {
+            title: t('操作'),
+            key: 'action',
+            render: (_, record) => (
+                <div className='flex gap-2'>
+                    <Button
+                        type='tertiary'
+                        size='small'
+                        onClick={() => openProductModal(record)}
+                    >
+                        {t('编辑')}
+                    </Button>
+                    <Button
+                        type='danger'
+                        theme='borderless'
+                        size='small'
+                        icon={<Trash2 size={14} />}
+                        onClick={() => deleteProduct(record.productId)}
+                    />
+                </div>
+            ),
+        },
+    ];
+
+    return (
+        <Spin spinning={loading}>
+            <Form
+                initValues={inputs}
+                onValueChange={handleFormChange}
+                getFormApi={(api) => (formApiRef.current = api)}
+            >
+                <Form.Section text={t('Creem 设置')}>
+                    <Text>
+                        {t('Creem 介绍')}
+                        <a
+                            href='https://creem.io'
+                            target='_blank'
+                            rel='noreferrer'
+                        >Creem Official Site</a>
+                        <br />
+                    </Text>
+                    <Banner
+                        type='info'
+                        description={t('Creem Setting Tips')}
+                    />
+
+                    <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
+                        <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+                            <Form.Input
+                                field='CreemApiKey'
+                                label={t('API 密钥')}
+                                placeholder={t('Creem API 密钥,敏感信息不显示')}
+                                type='password'
+                            />
+                        </Col>
+                        <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+                            <Form.Input
+                                field='CreemWebhookSecret'
+                                label={t('Webhook 密钥')}
+                                placeholder={t('用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示')}
+                                type='password'
+                            />
+                        </Col>
+                        <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+                            <Form.Switch
+                                field='CreemTestMode'
+                                label={t('测试模式')}
+                                extraText={t('启用后将使用 Creem Test Mode')}
+                            />
+                        </Col>
+                    </Row>
+
+                    <div style={{ marginTop: 24 }}>
+                        <div className='flex justify-between items-center mb-4'>
+                            <Text strong>{t('产品配置')}</Text>
+                            <Button
+                                type='primary'
+                                icon={<Plus size={16} />}
+                                onClick={() => openProductModal()}
+                            >
+                                {t('添加产品')}
+                            </Button>
+                        </div>
+
+                        <Table
+                            columns={columns}
+                            dataSource={products}
+                            pagination={false}
+                            empty={
+                                <div className='text-center py-8'>
+                                    <Text type='tertiary'>{t('暂无产品配置')}</Text>
+                                </div>
+                            }
+                        />
+                    </div>
+
+                    <Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
+                        {t('更新 Creem 设置')}
+                    </Button>
+                </Form.Section>
+            </Form>
+
+            {/* 产品配置模态框 */}
+            <Modal
+                title={editingProduct ? t('编辑产品') : t('添加产品')}
+                visible={showProductModal}
+                onOk={saveProduct}
+                onCancel={closeProductModal}
+                maskClosable={false}
+                size='small'
+                centered
+            >
+                <div className='space-y-4'>
+                    <div>
+                        <Text strong className='block mb-2'>
+                            {t('产品名称')}
+                        </Text>
+                        <Input
+                            value={productForm.name}
+                            onChange={(value) => setProductForm({ ...productForm, name: value })}
+                            placeholder={t('例如:基础套餐')}
+                            size='large'
+                        />
+                    </div>
+                    <div>
+                        <Text strong className='block mb-2'>
+                            {t('产品ID')}
+                        </Text>
+                        <Input
+                            value={productForm.productId}
+                            onChange={(value) => setProductForm({ ...productForm, productId: value })}
+                            placeholder={t('例如:prod_6I8rBerHpPxyoiU9WK4kot')}
+                            size='large'
+                            disabled={!!editingProduct}
+                        />
+                    </div>
+                    <div>
+                        <Text strong className='block mb-2'>
+                            {t('货币')}
+                        </Text>
+                        <Select
+                            value={productForm.currency}
+                            onChange={(value) => setProductForm({ ...productForm, currency: value })}
+                            size='large'
+                            className='w-full'
+                        >
+                            <Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
+                            <Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
+                        </Select>
+                    </div>
+                    <div>
+                        <Text strong className='block mb-2'>
+                            {t('价格')} ({productForm.currency === 'EUR' ? t('欧元') : t('美元')})
+                        </Text>
+                        <InputNumber
+                            value={productForm.price}
+                            onChange={(value) => setProductForm({ ...productForm, price: value })}
+                            placeholder={t('例如:4.99')}
+                            min={0.01}
+                            precision={2}
+                            size='large'
+                            className='w-full'
+                            defaultValue={4.49}
+                        />
+                    </div>
+                    <div>
+                        <Text strong className='block mb-2'>
+                            {t('充值额度')}
+                        </Text>
+                        <InputNumber
+                            value={productForm.quota}
+                            onChange={(value) => setProductForm({ ...productForm, quota: value })}
+                            placeholder={t('例如:100000')}
+                            min={1}
+                            precision={0}
+                            size='large'
+                            className='w-full'
+                        />
+                    </div>
+                </div>
+            </Modal>
+        </Spin>
+    );
+}