完善 订单处理,以及 优化 ui

This commit is contained in:
Little Write
2025-09-16 22:35:46 +08:00
parent dc6fbffa96
commit a7d6a8b0d0
6 changed files with 618 additions and 378 deletions

View File

@@ -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
}

View File

@@ -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":

View File

@@ -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
}

View File

@@ -3,3 +3,4 @@ package setting
var CreemApiKey = ""
var CreemProducts = "[]"
var CreemTestMode = false
var CreemWebhookSecret = ""

View File

@@ -28,6 +28,7 @@ const PaymentSetting = () => {
StripeMinTopUp: 1,
CreemApiKey: '',
CreemWebhookSecret: '',
CreemProducts: '[]',
});

View File

@@ -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) => (
<div className='flex gap-2'>
<Button
type='tertiary'
size='small'
onClick={() => openProductModal(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
theme='borderless'
size='small'
icon={<Trash2 size={14} />}
onClick={() => deleteProduct(record.productId)}
/>
</div>
),
},
];
if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
}
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Creem 设置')}>
<Text>
Creem 是一个简单的支付处理平台支持固定金额的产品销售请在
<a
href='https://creem.io'
target='_blank'
rel='noreferrer'
>
Creem 官网
</a>
创建账户并获取 API 密钥
<br />
</Text>
<Banner
type='info'
description={t('Creem 只支持预设的固定金额产品,不支持自定义金额充值')}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='CreemApiKey'
label={t('API 密钥')}
placeholder={t('creem_xxx 的 Creem API 密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Switch
field='CreemTestMode'
label={t('测试模式')}
extraText={t('启用后将使用 Creem 测试环境,可使用测试卡号 4242 4242 4242 4242 进行测试')}
/>
</Col>
</Row>
if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
}
<div style={{ marginTop: 24 }}>
<div className='flex justify-between items-center mb-4'>
<Text strong>{t('产品配置')}</Text>
<Button
type='primary'
icon={<Plus size={16} />}
onClick={() => openProductModal()}
>
{t('添加产品')}
</Button>
</div>
<Table
columns={columns}
dataSource={products}
pagination={false}
empty={
<div className='text-center py-8'>
<Text type='tertiary'>{t('暂无产品配置')}</Text>
// Save test mode setting
options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
// Save products as JSON string
options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
// 发送请求
const requestQueue = options.map(opt =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
})
);
const results = await Promise.all(requestQueue);
// 检查所有请求是否成功
const errorResults = results.filter(res => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach(res => {
showError(res.data.message);
});
} else {
showSuccess(t('更新成功'));
// 更新本地存储的原始值
setOriginInputs({ ...inputs });
props.refresh?.();
}
} catch (error) {
showError(t('更新失败'));
}
setLoading(false);
};
const openProductModal = (product = null) => {
if (product) {
setEditingProduct(product);
setProductForm({ ...product });
} else {
setEditingProduct(null);
setProductForm({
name: '',
productId: '',
price: 0,
quota: 0,
currency: 'USD',
});
}
setShowProductModal(true);
};
const closeProductModal = () => {
setShowProductModal(false);
setEditingProduct(null);
setProductForm({
name: '',
productId: '',
price: 0,
quota: 0,
currency: 'USD',
});
};
const saveProduct = () => {
if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) {
showError(t('请填写完整的产品信息'));
return;
}
let newProducts = [...products];
if (editingProduct) {
// 编辑现有产品
const index = newProducts.findIndex(p => p.productId === editingProduct.productId);
if (index !== -1) {
newProducts[index] = { ...productForm };
}
} else {
// 添加新产品
if (newProducts.find(p => p.productId === productForm.productId)) {
showError(t('产品ID已存在'));
return;
}
newProducts.push({ ...productForm });
}
setProducts(newProducts);
closeProductModal();
};
const deleteProduct = (productId) => {
const newProducts = products.filter(p => p.productId !== productId);
setProducts(newProducts);
};
const columns = [
{
title: t('产品名称'),
dataIndex: 'name',
key: 'name',
},
{
title: t('产品ID'),
dataIndex: 'productId',
key: 'productId',
},
{
title: t('价格'),
dataIndex: 'price',
key: 'price',
render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`,
},
{
title: t('充值额度'),
dataIndex: 'quota',
key: 'quota',
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<div className='flex gap-2'>
<Button
type='tertiary'
size='small'
onClick={() => openProductModal(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
theme='borderless'
size='small'
icon={<Trash2 size={14} />}
onClick={() => deleteProduct(record.productId)}
/>
</div>
}
/>
</div>
),
},
];
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
{t('更新 Creem 设置')}
</Button>
</Form.Section>
</Form>
{/* 产品配置模态框 */}
<Modal
title={editingProduct ? t('编辑产品') : t('添加产品')}
visible={showProductModal}
onOk={saveProduct}
onCancel={closeProductModal}
maskClosable={false}
size='small'
centered
>
<div className='space-y-4'>
<div>
<Text strong className='block mb-2'>
{t('产品名称')}
</Text>
<Input
value={productForm.name}
onChange={(value) => setProductForm({ ...productForm, name: value })}
placeholder={t('例如:基础套餐')}
size='large'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('产品ID')}
</Text>
<Input
value={productForm.productId}
onChange={(value) => setProductForm({ ...productForm, productId: value })}
placeholder={t('例如prod_6I8rBerHpPxyoiU9WK4kot')}
size='large'
disabled={!!editingProduct}
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('货币')}
</Text>
<Select
value={productForm.currency}
onChange={(value) => setProductForm({ ...productForm, currency: value })}
size='large'
className='w-full'
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Select.Option value='USD'>USD (美元)</Select.Option>
<Select.Option value='EUR'>EUR (欧元)</Select.Option>
</Select>
</div>
<div>
<Text strong className='block mb-2'>
{t('价格')} ({productForm.currency === 'EUR' ? '欧元' : '美元'})
</Text>
<InputNumber
value={productForm.price}
onChange={(value) => setProductForm({ ...productForm, price: value })}
placeholder={t('例如4.99')}
min={0.01}
precision={2}
size='large'
className='w-full'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('充值额度')}
</Text>
<InputNumber
value={productForm.quota}
onChange={(value) => setProductForm({ ...productForm, quota: value })}
placeholder={t('例如100000')}
min={1}
precision={0}
size='large'
className='w-full'
/>
</div>
</div>
</Modal>
</Spin>
);
<Form.Section text={t('Creem 设置')}>
<Text>
Creem 是一个简单的支付处理平台支持固定金额的产品销售请在
<a
href='https://creem.io'
target='_blank'
rel='noreferrer'
>
Creem 官网
</a>
创建账户并获取 API 密钥
<br />
</Text>
<Banner
type='info'
description={t('Creem 只支持预设的固定金额产品,不支持自定义金额充值')}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemApiKey'
label={t('API 密钥')}
placeholder={t('creem_xxx 的 Creem API 密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemWebhookSecret'
label={t('Webhook 密钥')}
placeholder={t('用于验证 Webhook 请求的密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='CreemTestMode'
label={t('测试模式')}
extraText={t('启用后将使用 Creem Test Mode')}
/>
</Col>
</Row>
<div style={{ marginTop: 24 }}>
<div className='flex justify-between items-center mb-4'>
<Text strong>{t('产品配置')}</Text>
<Button
type='primary'
icon={<Plus size={16} />}
onClick={() => openProductModal()}
>
{t('添加产品')}
</Button>
</div>
<Table
columns={columns}
dataSource={products}
pagination={false}
empty={
<div className='text-center py-8'>
<Text type='tertiary'>{t('暂无产品配置')}</Text>
</div>
}
/>
</div>
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
{t('更新 Creem 设置')}
</Button>
</Form.Section>
</Form>
{/* 产品配置模态框 */}
<Modal
title={editingProduct ? t('编辑产品') : t('添加产品')}
visible={showProductModal}
onOk={saveProduct}
onCancel={closeProductModal}
maskClosable={false}
size='small'
centered
>
<div className='space-y-4'>
<div>
<Text strong className='block mb-2'>
{t('产品名称')}
</Text>
<Input
value={productForm.name}
onChange={(value) => setProductForm({ ...productForm, name: value })}
placeholder={t('例如:基础套餐')}
size='large'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('产品ID')}
</Text>
<Input
value={productForm.productId}
onChange={(value) => setProductForm({ ...productForm, productId: value })}
placeholder={t('例如prod_6I8rBerHpPxyoiU9WK4kot')}
size='large'
disabled={!!editingProduct}
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('货币')}
</Text>
<Select
value={productForm.currency}
onChange={(value) => setProductForm({ ...productForm, currency: value })}
size='large'
className='w-full'
>
<Select.Option value='USD'>USD (美元)</Select.Option>
<Select.Option value='EUR'>EUR (欧元)</Select.Option>
</Select>
</div>
<div>
<Text strong className='block mb-2'>
{t('价格')} ({productForm.currency === 'EUR' ? '欧元' : '美元'})
</Text>
<InputNumber
value={productForm.price}
onChange={(value) => setProductForm({ ...productForm, price: value })}
placeholder={t('例如4.99')}
min={0.01}
precision={2}
size='large'
className='w-full'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('充值额度')}
</Text>
<InputNumber
value={productForm.quota}
onChange={(value) => setProductForm({ ...productForm, quota: value })}
placeholder={t('例如100000')}
min={1}
precision={0}
size='large'
className='w-full'
/>
</div>
</div>
</Modal>
</Spin>
);
}