From 51a7aa440b6c427b192978f5434bd5fa99107fc8 Mon Sep 17 00:00:00 2001 From: Little Write Date: Mon, 8 Sep 2025 23:07:05 +0800 Subject: [PATCH 1/9] =?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 a3ed9be9..8411a281 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 00000000..c02a8699 --- /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 05b99b41..8577c78f 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 c34c0ce6..a208ecae 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 bc49803a..49690dc0 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 00000000..8aa6e7de --- /dev/null +++ b/setting/payment_creem.go @@ -0,0 +1,5 @@ +package setting + +var CreemApiKey = "" +var CreemProducts = "[]" +var CreemTestMode = false From edf46c701fca7962002a088829cb9124107bed1e Mon Sep 17 00:00:00 2001 From: Little Write Date: Mon, 8 Sep 2025 23:25:30 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=83=A8=E5=88=86?= =?UTF-8?q?=EF=BC=8C=E8=B0=83=E8=AF=95=20=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/settings/PaymentSetting.js | 16 + .../Payment/SettingsPaymentGatewayCreem.js | 373 ++++++++++++++++++ web/src/pages/TopUp/index.js | 244 +++++++++++- 3 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js diff --git a/web/src/components/settings/PaymentSetting.js b/web/src/components/settings/PaymentSetting.js index ed175a20..5d3677fc 100644 --- a/web/src/components/settings/PaymentSetting.js +++ b/web/src/components/settings/PaymentSetting.js @@ -3,9 +3,11 @@ import { Card, Spin } from '@douyinfe/semi-ui'; import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js'; import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js'; import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe.js'; +import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem.js'; import { API, showError, toBoolean } from '../../helpers'; import { useTranslation } from 'react-i18next'; + const PaymentSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ @@ -24,6 +26,9 @@ const PaymentSetting = () => { StripePriceId: '', StripeUnitPrice: 8.0, StripeMinTopUp: 1, + + CreemApiKey: '', + CreemProducts: '[]', }); let [loading, setLoading] = useState(false); @@ -43,6 +48,14 @@ const PaymentSetting = () => { newInputs[item.key] = item.value; } break; + case 'CreemProducts': + try { + newInputs[item.key] = item.value; + } catch (error) { + console.error('解析CreemProducts出错:', error); + newInputs[item.key] = '[]'; + } + break; case 'Price': case 'MinTopUp': case 'StripeUnitPrice': @@ -92,6 +105,9 @@ const PaymentSetting = () => { + + + ); diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js new file mode 100644 index 00000000..2e58b86d --- /dev/null +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js @@ -0,0 +1,373 @@ +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: '', + 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 || '', + 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 }); + } + + // 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)} + > + + + Creem 是一个简单的支付处理平台,支持固定金额的产品销售。请在 + + Creem 官网 + + 创建账户并获取 API 密钥。 +
+
+ + + + + + + + + + + +
+
+ {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' ? '欧元' : '美元'}) + + setProductForm({ ...productForm, price: value })} + placeholder={t('例如:4.99')} + min={0.01} + precision={2} + size='large' + className='w-full' + /> +
+
+ + {t('充值额度')} + + setProductForm({ ...productForm, quota: value })} + placeholder={t('例如:100000')} + min={1} + precision={0} + size='large' + className='w-full' + /> +
+
+
+ + ); +} \ No newline at end of file diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index a7ac6ba6..d7625685 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -66,6 +66,11 @@ const TopUp = () => { const [enableStripeTopUp, setEnableStripeTopUp] = useState(statusState?.status?.enable_stripe_topup || false); const [stripeOpen, setStripeOpen] = useState(false); + const [creemProducts, setCreemProducts] = useState([]); + const [enableCreemTopUp, setEnableCreemTopUp] = useState(false); + const [creemOpen, setCreemOpen] = useState(false); + const [selectedCreemProduct, setSelectedCreemProduct] = useState(null); + const [userQuota, setUserQuota] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); @@ -296,6 +301,50 @@ const TopUp = () => { window.open(data.pay_link, '_blank'); }; + const creemPreTopUp = async (product) => { + if (!enableCreemTopUp) { + showError(t('管理员未开启 Creem 充值!')); + return; + } + setSelectedCreemProduct(product); + setCreemOpen(true); + }; + + const onlineCreemTopUp = async () => { + if (!selectedCreemProduct) { + 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 () => { setUserDataLoading(true); let res = await API.get(`/api/user/self`); @@ -396,6 +445,15 @@ const TopUp = () => { setStripeMinTopUp(statusState.status.stripe_min_topup || 1); setStripeTopUpCount(statusState.status.stripe_min_topup || 1); setEnableStripeTopUp(statusState.status.enable_stripe_topup || false); + + // Creem settings + setEnableCreemTopUp(statusState.status.enable_creem_topup || false); + try { + const products = JSON.parse(statusState.status.creem_products || '[]'); + setCreemProducts(products); + } catch (e) { + setCreemProducts([]); + } } }, [statusState?.status]); @@ -470,6 +528,11 @@ const TopUp = () => { setStripeOpen(false); }; + const handleCreemCancel = () => { + setCreemOpen(false); + setSelectedCreemProduct(null); + }; + const handleTransferCancel = () => { setOpenTransfer(false); }; @@ -623,6 +686,32 @@ const TopUp = () => {

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

+ + {selectedCreemProduct && ( + <> +

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

+

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

+

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

+

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

+ + )} +
+
{/* 左侧充值区域 */}
@@ -925,7 +1014,7 @@ const TopUp = () => { )} - {!enableOnlineTopUp && !enableStripeTopUp && ( + {!enableOnlineTopUp && !enableStripeTopUp && !enableCreemTopUp && ( {
- + + {/* 移动端 Stripe 充值区域 */} +
+ + + {t('Stripe 充值')} + + + +
+
+ {t('充值数量')} + {amountLoading ? ( + + ) : ( + + {t('实付金额:') + renderStripeAmount()} + + )} +
+ { + if (value && value >= 1) { + setStripeTopUpCount(value); + setSelectedPreset(null); + await getStripeAmount(value); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value); + if (!value || value < 1) { + setStripeTopUpCount(1); + getStripeAmount(1); + } + }} + className='w-full' + formatter={(value) => (value ? `${value}` : '')} + parser={(value) => + value ? parseInt(value.replace(/[^\d]/g, '')) : 0 + } + /> +
+ +
+ +
+
+ + )} + + {enableCreemTopUp && creemProducts.length > 0 && ( + <> +
+ + + {t('Creem 充值')} + + + +
+ + {t('选择充值套餐')} + +
+ {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} +
+
+ ))} +
+
+
+ + {/* 移动端 Creem 充值区域 */} +
+ + + {t('Creem 充值')} + + + +
+ + {t('选择充值套餐')} + +
+ {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} +
+
+ ))} +
+
+
+ )} @@ -1185,7 +1418,12 @@ const TopUp = () => { - {/* 移动端底部固定的自定义金额和支付区域 */} + {/* 移动端底部间距,避免内容被固定区域遮挡 */} + {enableOnlineTopUp && ( +
+ )} + + {/* 移动端底部固定的自定义金额和支付区域 - 仅限在线充值 */} {enableOnlineTopUp && (
Date: Tue, 16 Sep 2025 22:35:46 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E5=AE=8C=E5=96=84=20=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=A4=84=E7=90=86=EF=BC=8C=E4=BB=A5=E5=8F=8A=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/topup_creem.go | 257 ++++++- model/option.go | 3 + model/topup.go | 28 +- setting/payment_creem.go | 1 + web/src/components/settings/PaymentSetting.js | 1 + .../Payment/SettingsPaymentGatewayCreem.js | 706 +++++++++--------- 6 files changed, 618 insertions(+), 378 deletions(-) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index c02a8699..7fc9e4b9 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -2,6 +2,9 @@ package controller import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -17,11 +20,30 @@ import ( ) const ( - PaymentMethodCreem = "creem" + 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未配置,跳过签名验证") + return true // 如果没有配置secret,跳过验证 + } + + expectedSignature := generateCreemSignature(payload, secret) + return hmac.Equal([]byte(signature), []byte(expectedSignature)) +} + type CreemPayRequest struct { ProductId string `json:"product_id"` PaymentMethod string `json:"payment_method"` @@ -75,41 +97,65 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { 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)) - 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, + 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 - err := c.ShouldBindJSON(&req) + + // 读取body内容用于打印,同时保留原始数据供后续使用 + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("读取请求body失败: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "读取请求失败"}) + 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) + log.Printf(" json body is %+v", req) if err != nil { c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) return @@ -117,6 +163,68 @@ func RequestCreemPay(c *gin.Context) { 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 { @@ -127,38 +235,122 @@ type CreemWebhookData struct { } func CreemWebhook(c *gin.Context) { - // 解析 webhook 数据 - var webhookData CreemWebhookData - if err := c.ShouldBindJSON(&webhookData); err != nil { - log.Printf("解析Creem Webhook参数失败: %v\n", err) + // 读取body内容用于打印,同时保留原始数据供后续使用 + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("读取Creem Webhook请求body失败: %v", err) c.AbortWithStatus(http.StatusBadRequest) return } - // 检查事件类型 - if webhookData.Type != "checkout.completed" { - log.Printf("忽略Creem Webhook事件类型: %s", webhookData.Type) + // 获取签名头 + signature := c.GetHeader(CreemSignatureHeader) + + // 打印请求信息用于调试 + log.Printf("Creem Webhook - URI: %s, Query: %s", c.Request.RequestURI, c.Request.URL.RawQuery) + log.Printf("Creem Webhook - Signature: %s", signature) + log.Printf("Creem Webhook - Body: %s", string(bodyBytes)) + + // 验证签名 + 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 - referenceId := webhookData.Data.RequestId + // 获取引用ID(这是我们创建订单时传递的request_id) + referenceId := event.Object.RequestId if referenceId == "" { log.Println("Creem Webhook缺少request_id字段") c.AbortWithStatus(http.StatusBadRequest) return } - // 处理支付完成事件 - err := model.RechargeCreem(referenceId) + // 验证订单类型,目前只处理一次性付款 + 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, 产品: %s", + referenceId, + event.Object.Order.Id, + event.Object.Order.AmountPaid, + event.Object.Order.Currency, + event.Object.Customer.Email, + 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.Println("Creem充值处理失败:", err.Error(), referenceId) + log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId) c.AbortWithStatus(http.StatusInternalServerError) return } - log.Printf("Creem充值成功: %s", referenceId) + log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f, 客户邮箱: %s, 客户姓名: %s", + referenceId, topUp.Amount, topUp.Money, customerEmail, customerName) c.Status(http.StatusOK) } @@ -185,20 +377,23 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern 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, + RequestId: referenceId, // 这个作为订单ID传递给Creem Customer: struct { Email string `json:"email"` }{ - Email: email, + Email: email, // 用户邮箱会在支付页面预填充 }, Metadata: map[string]string{ "username": username, "reference_id": referenceId, + "product_name": product.Name, + "quota": fmt.Sprintf("%d", product.Quota), }, } @@ -218,6 +413,9 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern 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, @@ -227,7 +425,6 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern 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) @@ -235,6 +432,8 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern return "", fmt.Errorf("读取响应失败: %v", err) } + log.Printf("Creem API响应 - 状态码: %d, 响应体: %s", resp.StatusCode, string(body)) + // 检查响应状态 if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("Creem API 返回错误状态 %d: %s", resp.StatusCode, string(body)) @@ -251,6 +450,6 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern return "", fmt.Errorf("Creem API 未返回支付链接") } - log.Printf("Creem 支付链接创建成功: %s, 订单ID: %s", referenceId, checkoutResp.Id) + log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl) return checkoutResp.CheckoutUrl, nil } diff --git a/model/option.go b/model/option.go index 8577c78f..bc93945f 100644 --- a/model/option.go +++ b/model/option.go @@ -84,6 +84,7 @@ func InitOptionMap() { 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() @@ -335,6 +336,8 @@ func updateOptionMap(key string, value string) (err error) { 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 a208ecae..3f9ee3cb 100644 --- a/model/topup.go +++ b/model/topup.go @@ -99,7 +99,7 @@ func Recharge(referenceId string, customerId string) (err error) { return nil } -func RechargeCreem(referenceId string) (err error) { +func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) { if referenceId == "" { return errors.New("未提供支付单号") } @@ -131,7 +131,29 @@ func RechargeCreem(referenceId string) (err error) { // Creem 直接使用 Amount 作为充值额度 quota = float64(topUp.Amount) - err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quota)).Error + + // 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名 + 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 + fmt.Printf("更新用户邮箱:用户ID %d, 新邮箱 %s\n", topUp.UserId, customerEmail) + } + } + + err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error if err != nil { return err } @@ -143,7 +165,7 @@ func RechargeCreem(referenceId string) (err error) { return errors.New("充值失败," + err.Error()) } - RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", common.FormatQuota(int(quota)), topUp.Money)) + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f,客户邮箱:%s", common.FormatQuota(int(quota)), topUp.Money, customerEmail)) return nil } diff --git a/setting/payment_creem.go b/setting/payment_creem.go index 8aa6e7de..0e6b7ee2 100644 --- a/setting/payment_creem.go +++ b/setting/payment_creem.go @@ -3,3 +3,4 @@ package setting var CreemApiKey = "" var CreemProducts = "[]" var CreemTestMode = false +var CreemWebhookSecret = "" diff --git a/web/src/components/settings/PaymentSetting.js b/web/src/components/settings/PaymentSetting.js index 5d3677fc..01884c96 100644 --- a/web/src/components/settings/PaymentSetting.js +++ b/web/src/components/settings/PaymentSetting.js @@ -28,6 +28,7 @@ const PaymentSetting = () => { StripeMinTopUp: 1, CreemApiKey: '', + CreemWebhookSecret: '', CreemProducts: '[]', }); diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js index 2e58b86d..3c2a4141 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js @@ -1,373 +1,387 @@ import React, { useEffect, useState, useRef } from 'react'; import { - Banner, - Button, - Form, - Row, - Col, - Typography, - Spin, - Table, - Modal, - Input, - InputNumber, - Select, + Banner, + Button, + Form, + Row, + Col, + Typography, + Spin, + Table, + Modal, + Input, + InputNumber, + Select, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { - API, - showError, - showSuccess, + 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: '', - 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 || '', - 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 }); - } - - // 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({ + 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', - }); - } - setShowProductModal(true); - }; - - const closeProductModal = () => { - setShowProductModal(false); - setEditingProduct(null); - setProductForm({ - name: '', - productId: '', - price: 0, - quota: 0, - currency: 'USD', }); - }; + const formApiRef = useRef(null); - const saveProduct = () => { - if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) { - showError(t('请填写完整的产品信息')); - return; - } + 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); - 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 }); - } + // Parse products + try { + const parsedProducts = JSON.parse(currentInputs.CreemProducts); + setProducts(parsedProducts); + } catch (e) { + setProducts([]); + } + } + }, [props.options]); - setProducts(newProducts); - closeProductModal(); - }; + const handleFormChange = (values) => { + setInputs(values); + }; - const deleteProduct = (productId) => { - const newProducts = products.filter(p => p.productId !== productId); - setProducts(newProducts); - }; + const submitCreemSetting = async () => { + setLoading(true); + try { + const options = []; - 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) => ( -
- -
- ), - }, - ]; + if (inputs.CreemApiKey && inputs.CreemApiKey !== '') { + options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey }); + } - return ( - -
(formApiRef.current = api)} - > - - - Creem 是一个简单的支付处理平台,支持固定金额的产品销售。请在 - - Creem 官网 - - 创建账户并获取 API 密钥。 -
-
- - - -
- - - - - - + if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') { + options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret }); + } -
-
- {t('产品配置')} - -
- -
- {t('暂无产品配置')} + // 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) => ( +
+ +
- } - /> - + ), + }, + ]; - - - - - {/* 产品配置模态框 */} - -
-
- - {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' ? '欧元' : '美元'}) - - setProductForm({ ...productForm, price: value })} - placeholder={t('例如:4.99')} - min={0.01} - precision={2} - size='large' - className='w-full' - /> -
-
- - {t('充值额度')} - - setProductForm({ ...productForm, quota: value })} - placeholder={t('例如:100000')} - min={1} - precision={0} - size='large' - className='w-full' - /> -
-
-
- - ); + + + Creem 是一个简单的支付处理平台,支持固定金额的产品销售。请在 + + Creem 官网 + + 创建账户并获取 API 密钥。 +
+
+ + + +
+ + + + + + + + + + +
+
+ {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' ? '欧元' : '美元'}) + + setProductForm({ ...productForm, price: value })} + placeholder={t('例如:4.99')} + min={0.01} + precision={2} + size='large' + className='w-full' + /> +
+
+ + {t('充值额度')} + + setProductForm({ ...productForm, quota: value })} + placeholder={t('例如:100000')} + min={1} + precision={0} + size='large' + className='w-full' + /> +
+
+
+ + ); } \ No newline at end of file From cfc96c9c93315f00c8f812ee4bafb8b835068ad3 Mon Sep 17 00:00:00 2001 From: Little Write <773821422@qq.com> Date: Sat, 27 Sep 2025 11:40:19 +0800 Subject: [PATCH 4/9] Update controller/topup_creem.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- controller/topup_creem.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 7fc9e4b9..334410b3 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -36,8 +36,11 @@ func generateCreemSignature(payload string, secret string) string { // 验证Creem webhook签名 func verifyCreemSignature(payload string, signature string, secret string) bool { if secret == "" { - log.Printf("Creem webhook secret未配置,跳过签名验证") - return true // 如果没有配置secret,跳过验证 + if setting.CreemTestMode { + log.Printf("Creem webhook secret未配置,测试模式下跳过签名验证") + return true + } + return false } expectedSignature := generateCreemSignature(payload, secret) From 2a76fab170a3634ce9161cc722f56e972eca0179 Mon Sep 17 00:00:00 2001 From: Little Write <773821422@qq.com> Date: Sat, 27 Sep 2025 11:41:03 +0800 Subject: [PATCH 5/9] Update controller/topup_creem.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- controller/topup_creem.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 334410b3..cbc6bdd5 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -249,10 +249,13 @@ func CreemWebhook(c *gin.Context) { // 获取签名头 signature := c.GetHeader(CreemSignatureHeader) - // 打印请求信息用于调试 - log.Printf("Creem Webhook - URI: %s, Query: %s", c.Request.RequestURI, c.Request.URL.RawQuery) - log.Printf("Creem Webhook - Signature: %s", signature) - log.Printf("Creem Webhook - Body: %s", string(bodyBytes)) + // 打印关键信息(避免输出完整敏感payload) + log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI) + if signature == "" && !setting.CreemTestMode { + log.Printf("Creem Webhook缺少签名头") + c.AbortWithStatus(http.StatusUnauthorized) + return + } // 验证签名 if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) { From 90e66fe77435e46e7e3b4c026861a89e5abe23e4 Mon Sep 17 00:00:00 2001 From: Little Write <773821422@qq.com> Date: Sat, 27 Sep 2025 11:41:34 +0800 Subject: [PATCH 6/9] Update controller/topup_creem.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- controller/topup_creem.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index cbc6bdd5..76968a3d 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -441,10 +441,9 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern log.Printf("Creem API响应 - 状态码: %d, 响应体: %s", resp.StatusCode, string(body)) // 检查响应状态 - if resp.StatusCode != http.StatusOK { + if resp.StatusCode/100 != 2 { return "", fmt.Errorf("Creem API 返回错误状态 %d: %s", resp.StatusCode, string(body)) } - // 解析响应 var checkoutResp CreemCheckoutResponse err = json.Unmarshal(body, &checkoutResp) From 447166acb924de3973647388791711638073ce32 Mon Sep 17 00:00:00 2001 From: Little Write <773821422@qq.com> Date: Sat, 27 Sep 2025 11:42:18 +0800 Subject: [PATCH 7/9] Update model/topup.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- model/topup.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/model/topup.go b/model/topup.go index 3f9ee3cb..d621918b 100644 --- a/model/topup.go +++ b/model/topup.go @@ -98,13 +98,12 @@ func Recharge(referenceId string, customerId string) (err error) { return nil } - -func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) { +func RechargeCreem(referenceId string, customerEmail string) (err error) { if referenceId == "" { return errors.New("未提供支付单号") } - var quota float64 + var quota int64 topUp := &TopUp{} refCol := "`trade_no`" @@ -129,8 +128,8 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return err } - // Creem 直接使用 Amount 作为充值额度 - quota = float64(topUp.Amount) + // Creem 直接使用 Amount 作为充值额度(整数) + quota = topUp.Amount // 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名 updateFields := map[string]interface{}{ @@ -149,7 +148,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string // 如果用户邮箱为空,则更新为支付时使用的邮箱 if user.Email == "" { updateFields["email"] = customerEmail - fmt.Printf("更新用户邮箱:用户ID %d, 新邮箱 %s\n", topUp.UserId, customerEmail) + // 避免输出敏感信息到stdout } } @@ -165,7 +164,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return errors.New("充值失败," + err.Error()) } - RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f,客户邮箱:%s", common.FormatQuota(int(quota)), topUp.Money, customerEmail)) + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", common.FormatQuota(int(quota)), topUp.Money)) return nil } From 3251dc0bc8494350f22728096991c664331e6137 Mon Sep 17 00:00:00 2001 From: Little Write Date: Sat, 27 Sep 2025 18:04:48 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E8=B0=83=E6=95=B4=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/topup_creem.go | 25 +-- model/topup.go | 3 +- web/src/components/settings/PaymentSetting.js | 7 +- web/src/pages/TopUp/index.js | 169 +++++++++--------- 4 files changed, 102 insertions(+), 102 deletions(-) diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 76968a3d..a22868b8 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -36,8 +36,9 @@ func generateCreemSignature(payload string, secret string) string { // 验证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("Creem webhook secret未配置,测试模式下跳过签名验证") + log.Printf("Skip Creem webhook sign verify in test mode") return true } return false @@ -146,8 +147,8 @@ func RequestCreemPay(c *gin.Context) { // 读取body内容用于打印,同时保留原始数据供后续使用 bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { - log.Printf("读取请求body失败: %v", err) - c.JSON(200, gin.H{"message": "error", "data": "读取请求失败"}) + log.Printf("read creem pay req body err: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "read query error"}) return } @@ -158,7 +159,6 @@ func RequestCreemPay(c *gin.Context) { c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) err = c.ShouldBindJSON(&req) - log.Printf(" json body is %+v", req) if err != nil { c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) return @@ -251,7 +251,9 @@ func CreemWebhook(c *gin.Context) { // 打印关键信息(避免输出完整敏感payload) log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI) - if signature == "" && !setting.CreemTestMode { + 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 @@ -314,12 +316,11 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) { } // 记录详细的支付信息 - log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: %s, 产品: %s", + 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.Customer.Email, event.Object.Product.Name) // 查询本地订单确认存在 @@ -355,8 +356,8 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) { return } - log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f, 客户邮箱: %s, 客户姓名: %s", - referenceId, topUp.Amount, topUp.Money, customerEmail, customerName) + log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f", + referenceId, topUp.Amount, topUp.Money) c.Status(http.StatusOK) } @@ -438,11 +439,11 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern return "", fmt.Errorf("读取响应失败: %v", err) } - log.Printf("Creem API响应 - 状态码: %d, 响应体: %s", resp.StatusCode, string(body)) + log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body)) // 检查响应状态 if resp.StatusCode/100 != 2 { - return "", fmt.Errorf("Creem API 返回错误状态 %d: %s", resp.StatusCode, string(body)) + return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode) } // 解析响应 var checkoutResp CreemCheckoutResponse @@ -452,7 +453,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern } if checkoutResp.CheckoutUrl == "" { - return "", fmt.Errorf("Creem API 未返回支付链接") + return "", fmt.Errorf("Creem API resp no checkout url ") } log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl) diff --git a/model/topup.go b/model/topup.go index d621918b..8b4ab0c2 100644 --- a/model/topup.go +++ b/model/topup.go @@ -98,7 +98,7 @@ func Recharge(referenceId string, customerId string) (err error) { return nil } -func RechargeCreem(referenceId string, customerEmail string) (err error) { +func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) { if referenceId == "" { return errors.New("未提供支付单号") } @@ -148,7 +148,6 @@ func RechargeCreem(referenceId string, customerEmail string) (err error) { // 如果用户邮箱为空,则更新为支付时使用的邮箱 if user.Email == "" { updateFields["email"] = customerEmail - // 避免输出敏感信息到stdout } } diff --git a/web/src/components/settings/PaymentSetting.js b/web/src/components/settings/PaymentSetting.js index 01884c96..044d36de 100644 --- a/web/src/components/settings/PaymentSetting.js +++ b/web/src/components/settings/PaymentSetting.js @@ -50,12 +50,7 @@ const PaymentSetting = () => { } break; case 'CreemProducts': - try { - newInputs[item.key] = item.value; - } catch (error) { - console.error('解析CreemProducts出错:', error); - newInputs[item.key] = '[]'; - } + newInputs[item.key] = item.value || '[]'; break; case 'Price': case 'MinTopUp': diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index d7625685..e4b26925 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -315,6 +315,11 @@ const TopUp = () => { 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', { @@ -668,14 +673,14 @@ const TopUp = () => {

{t('充值数量')}:{stripeTopUpCount} @@ -1026,85 +1031,85 @@ const TopUp = () => { )} {enableStripeTopUp && ( - <> - {/* 桌面端显示的自定义金额和支付按钮 */} -

- - - {t(!enableOnlineTopUp ? '或输入自定义金额' : 'Stripe')} - - + <> + {/* 桌面端显示的自定义金额和支付按钮 */} +
+ + + {t(!enableOnlineTopUp ? '或输入自定义金额' : 'Stripe')} + + -
-
- {t('充值数量')} - {amountLoading ? ( - - ) : ( - - {t('实付金额:') + renderStripeAmount()} - - )} -
- { - if (value && value >= 1) { - setStripeTopUpCount(value); - setSelectedPreset(null); - await getStripeAmount(value); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value); - if (!value || value < 1) { - setStripeTopUpCount(1); - getStripeAmount(1); - } - }} - size='large' - className='w-full' - formatter={(value) => (value ? `${value}` : '')} - parser={(value) => - value ? parseInt(value.replace(/[^\d]/g, '')) : 0 - } - /> +
+
+ {t('充值数量')} + {amountLoading ? ( + + ) : ( + + {t('实付金额:') + renderStripeAmount()} + + )}
+ { + if (value && value >= 1) { + setStripeTopUpCount(value); + setSelectedPreset(null); + await getStripeAmount(value); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value); + if (!value || value < 1) { + setStripeTopUpCount(1); + getStripeAmount(1); + } + }} + size='large' + className='w-full' + formatter={(value) => (value ? `${value}` : '')} + parser={(value) => + value ? parseInt(value.replace(/[^\d]/g, '')) : 0 + } + /> +
-
- - {t('选择支付方式')} - -
- -
+
+ + {t('选择支付方式')} + +
+
+
{/* 移动端 Stripe 充值区域 */}
From 764aaef8d93ec8ec36e9f4834458877413753291 Mon Sep 17 00:00:00 2001 From: Little Write Date: Sat, 27 Sep 2025 21:22:09 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=86=20=E7=BF=BB?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/locales/en.json | 34 ++++++++++++++++++- web/src/i18n/locales/zh.json | 4 ++- .../Payment/SettingsPaymentGatewayCreem.js | 22 ++++++------ web/src/pages/TopUp/index.js | 12 +++---- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1ff11e1f..633ac9a4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1781,5 +1781,37 @@ "启用全部密钥": "Enable all keys", "以充值价格显示": "Show with recharge price", "美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)", - "美元汇率": "USD exchange rate" + "美元汇率": "USD exchange rate", + "选择支付方式": "Choose payment channel", + "选择充值套餐": "Choose a top-up package", + "Creem 设置": "Creem Setting", + "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.", + "API 密钥": "API Secret", + "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", +"更新成功": "Update successful", +"更新失败": "Update failed" } \ No newline at end of file diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 5c7904fc..ba19e085 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -9,5 +9,7 @@ "语言": "语言", "展开侧边栏": "展开侧边栏", "关闭侧边栏": "关闭侧边栏", - "注销成功!": "注销成功!" + "注销成功!": "注销成功!", + "Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。", + "Creem Setting Tips": "Creem 只支持预设的固定金额产品,这产品以及价格需要提前在Creem网站内创建配置,所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格,获取Product Id 后填到下面的产品,在new-api为该产品设置充值额度,以及展示价格。" } diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js index 3c2a4141..32e2e6fb 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js @@ -189,7 +189,7 @@ export default function SettingsPaymentGatewayCreem(props) { key: 'productId', }, { - title: t('价格'), + title: t('展示价格'), dataIndex: 'price', key: 'price', render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`, @@ -232,20 +232,17 @@ export default function SettingsPaymentGatewayCreem(props) { > - Creem 是一个简单的支付处理平台,支持固定金额的产品销售。请在 + {t('Creem 介绍')} - Creem 官网 - - 创建账户并获取 API 密钥。 + >Creem Official Site
@@ -253,7 +250,7 @@ export default function SettingsPaymentGatewayCreem(props) { @@ -261,7 +258,7 @@ export default function SettingsPaymentGatewayCreem(props) { @@ -348,13 +345,13 @@ export default function SettingsPaymentGatewayCreem(props) { size='large' className='w-full' > - USD (美元) - EUR (欧元) + {t('USD (美元)')} + {t('EUR (欧元)')}
- {t('价格')} ({productForm.currency === 'EUR' ? '欧元' : '美元'}) + {t('价格')} ({productForm.currency === 'EUR' ? t('欧元') : t('美元')})
diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index e4b26925..d0409d3d 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -673,7 +673,7 @@ const TopUp = () => { { { <>
- - {t('Creem 充值')} - + Creem
@@ -1223,9 +1221,7 @@ const TopUp = () => { {/* 移动端 Creem 充值区域 */}
- - {t('Creem 充值')} - + Creem