diff --git a/controller/topup.go b/controller/topup.go index 26d653606..f34d4b892 100644 --- a/controller/topup.go +++ b/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, diff --git a/controller/topup_creem.go b/controller/topup_creem.go new file mode 100644 index 000000000..a22868b80 --- /dev/null +++ b/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, 客户邮箱: , 产品: %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 +} diff --git a/model/option.go b/model/option.go index de4ca51b0..e9fd50d7f 100644 --- a/model/option.go +++ b/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": diff --git a/model/topup.go b/model/topup.go index 4f69da30a..994094d9d 100644 --- a/model/topup.go +++ b/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 +} diff --git a/router/api-router.go b/router/api-router.go index 6b0b934ee..d8d5857fc 100644 --- a/router/api-router.go +++ b/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) diff --git a/setting/payment_creem.go b/setting/payment_creem.go new file mode 100644 index 000000000..0e6b7ee2b --- /dev/null +++ b/setting/payment_creem.go @@ -0,0 +1,6 @@ +package setting + +var CreemApiKey = "" +var CreemProducts = "[]" +var CreemTestMode = false +var CreemWebhookSecret = "" diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index 220c86642..28cbf13b3 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/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 = () => { + + + ); diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 6ad8b0e8a..15c37dffb 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/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 ( {/* 卡片头部 */} @@ -216,7 +220,7 @@ const RechargeCard = ({
- ) : enableOnlineTopUp || enableStripeTopUp ? ( + ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
(onlineFormApiRef.current = api)} initValues={{ topUpCount: topUpCount }} @@ -480,6 +484,32 @@ const RechargeCard = ({ )} + + {/* Creem 充值区域 */} + {enableCreemTopUp && creemProducts.length > 0 && ( + +
+ {creemProducts.map((product, index) => ( + creemPreTopUp(product)} + className='cursor-pointer !rounded-2xl transition-all hover:shadow-md border-gray-200 hover:border-gray-300' + bodyStyle={{ textAlign: 'center', padding: '16px' }} + > +
+ {product.name} +
+
+ {t('充值额度')}: {product.quota} +
+
+ {product.currency === 'EUR' ? '€' : '$'}{product.price} +
+
+ ))} +
+
+ )}
) : ( diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 9054da524..4ff05a254 100644 --- a/web/src/components/topup/index.jsx +++ b/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 充值确认模态框 */} + + {selectedCreemProduct && ( + <> +

+ {t('产品名称')}:{selectedCreemProduct.name} +

+

+ {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price} +

+

+ {t('充值额度')}:{selectedCreemProduct.quota} +

+

{t('是否确认充值?')}

+ + )} +
+ {/* 用户信息头部 */}
@@ -572,6 +671,9 @@ const TopUp = () => { t={t} enableOnlineTopUp={enableOnlineTopUp} enableStripeTopUp={enableStripeTopUp} + enableCreemTopUp={enableCreemTopUp} + creemProducts={creemProducts} + creemPreTopUp={creemPreTopUp} presetAmounts={presetAmounts} selectedPreset={selectedPreset} selectPresetAmount={selectPresetAmount} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index db62d5f59..1b4195fcc 100644 --- a/web/src/i18n/locales/en.json +++ b/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" } -} +} \ No newline at end of file diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 62c8836be..bd43be977 100644 --- a/web/src/i18n/locales/zh.json +++ b/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为该产品设置充值额度,以及展示价格。" } -} +} \ No newline at end of file diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js new file mode 100644 index 000000000..32e2e6fbc --- /dev/null +++ b/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) => ( +
+ +
+ ), + }, + ]; + + return ( + +
(formApiRef.current = api)} + > + + + {t('Creem 介绍')} + Creem Official Site +
+
+ + + + + + + + + + + + + + +
+
+ {t('产品配置')} + +
+ + + {t('暂无产品配置')} + + } + /> + + + + + + + {/* 产品配置模态框 */} + +
+
+ + {t('产品名称')} + + setProductForm({ ...productForm, name: value })} + placeholder={t('例如:基础套餐')} + size='large' + /> +
+
+ + {t('产品ID')} + + setProductForm({ ...productForm, productId: value })} + placeholder={t('例如:prod_6I8rBerHpPxyoiU9WK4kot')} + size='large' + disabled={!!editingProduct} + /> +
+
+ + {t('货币')} + + +
+
+ + {t('价格')} ({productForm.currency === 'EUR' ? t('欧元') : t('美元')}) + + setProductForm({ ...productForm, price: value })} + placeholder={t('例如:4.99')} + min={0.01} + precision={2} + size='large' + className='w-full' + defaultValue={4.49} + /> +
+
+ + {t('充值额度')} + + setProductForm({ ...productForm, quota: value })} + placeholder={t('例如:100000')} + min={1} + precision={0} + size='large' + className='w-full' + /> +
+
+
+ + ); +} \ No newline at end of file