Merge pull request #1823 from littlewrite/feat_subscribe_sp1
新增 creem 支付
This commit is contained in:
@@ -51,6 +51,8 @@ func GetTopUpInfo(c *gin.Context) {
|
|||||||
data := gin.H{
|
data := gin.H{
|
||||||
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
|
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
|
||||||
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
||||||
|
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
|
||||||
|
"creem_products": setting.CreemProducts,
|
||||||
"pay_methods": payMethods,
|
"pay_methods": payMethods,
|
||||||
"min_topup": operation_setting.MinTopUp,
|
"min_topup": operation_setting.MinTopUp,
|
||||||
"stripe_min_topup": setting.StripeMinTopUp,
|
"stripe_min_topup": setting.StripeMinTopUp,
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"one-api/common"
|
||||||
|
"one-api/model"
|
||||||
|
"one-api/setting"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/thanhpk/randstr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PaymentMethodCreem = "creem"
|
||||||
|
CreemSignatureHeader = "creem-signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
var creemAdaptor = &CreemAdaptor{}
|
||||||
|
|
||||||
|
// 生成HMAC-SHA256签名
|
||||||
|
func generateCreemSignature(payload string, secret string) string {
|
||||||
|
h := hmac.New(sha256.New, []byte(secret))
|
||||||
|
h.Write([]byte(payload))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证Creem webhook签名
|
||||||
|
func verifyCreemSignature(payload string, signature string, secret string) bool {
|
||||||
|
if secret == "" {
|
||||||
|
log.Printf("Creem webhook secret not set")
|
||||||
|
if setting.CreemTestMode {
|
||||||
|
log.Printf("Skip Creem webhook sign verify in test mode")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSignature := generateCreemSignature(payload, secret)
|
||||||
|
return hmac.Equal([]byte(signature), []byte(expectedSignature))
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemPayRequest struct {
|
||||||
|
ProductId string `json:"product_id"`
|
||||||
|
PaymentMethod string `json:"payment_method"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemProduct struct {
|
||||||
|
ProductId string `json:"productId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Quota int64 `json:"quota"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemAdaptor struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||||
|
if req.PaymentMethod != PaymentMethodCreem {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ProductId == "" {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析产品列表
|
||||||
|
var products []CreemProduct
|
||||||
|
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("解析Creem产品列表失败", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找对应的产品
|
||||||
|
var selectedProduct *CreemProduct
|
||||||
|
for _, product := range products {
|
||||||
|
if product.ProductId == req.ProductId {
|
||||||
|
selectedProduct = &product
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedProduct == nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := c.GetInt("id")
|
||||||
|
user, _ := model.GetUserById(id, false)
|
||||||
|
|
||||||
|
// 生成唯一的订单引用ID
|
||||||
|
reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||||
|
referenceId := "ref_" + common.Sha1([]byte(reference))
|
||||||
|
|
||||||
|
// 先创建订单记录,使用产品配置的金额和充值额度
|
||||||
|
topUp := &model.TopUp{
|
||||||
|
UserId: id,
|
||||||
|
Amount: selectedProduct.Quota, // 充值额度
|
||||||
|
Money: selectedProduct.Price, // 支付金额
|
||||||
|
TradeNo: referenceId,
|
||||||
|
CreateTime: time.Now().Unix(),
|
||||||
|
Status: common.TopUpStatusPending,
|
||||||
|
}
|
||||||
|
err = topUp.Insert()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("创建Creem订单失败: %v", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建支付链接,传入用户邮箱
|
||||||
|
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("获取Creem支付链接失败: %v", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
|
||||||
|
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"message": "success",
|
||||||
|
"data": gin.H{
|
||||||
|
"checkout_url": checkoutUrl,
|
||||||
|
"order_id": referenceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestCreemPay(c *gin.Context) {
|
||||||
|
var req CreemPayRequest
|
||||||
|
|
||||||
|
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||||
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("read creem pay req body err: %v", err)
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印body内容
|
||||||
|
log.Printf("creem pay request body: %s", string(bodyBytes))
|
||||||
|
|
||||||
|
// 重新设置body供后续的ShouldBindJSON使用
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
|
||||||
|
err = c.ShouldBindJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creemAdaptor.RequestPay(c, &req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新的Creem Webhook结构体,匹配实际的webhook数据格式
|
||||||
|
type CreemWebhookEvent struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Object struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
RequestId string `json:"request_id"`
|
||||||
|
Order struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
Customer string `json:"customer"`
|
||||||
|
Product string `json:"product"`
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
SubTotal int `json:"sub_total"`
|
||||||
|
TaxAmount int `json:"tax_amount"`
|
||||||
|
AmountDue int `json:"amount_due"`
|
||||||
|
AmountPaid int `json:"amount_paid"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Transaction string `json:"transaction"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"order"`
|
||||||
|
Product struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Price int `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
BillingType string `json:"billing_type"`
|
||||||
|
BillingPeriod string `json:"billing_period"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TaxMode string `json:"tax_mode"`
|
||||||
|
TaxCategory string `json:"tax_category"`
|
||||||
|
DefaultSuccessUrl *string `json:"default_success_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"product"`
|
||||||
|
Units int `json:"units"`
|
||||||
|
Customer struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"customer"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
} `json:"object"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留旧的结构体作为兼容
|
||||||
|
type CreemWebhookData struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data struct {
|
||||||
|
RequestId string `json:"request_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreemWebhook(c *gin.Context) {
|
||||||
|
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||||
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("读取Creem Webhook请求body失败: %v", err)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取签名头
|
||||||
|
signature := c.GetHeader(CreemSignatureHeader)
|
||||||
|
|
||||||
|
// 打印关键信息(避免输出完整敏感payload)
|
||||||
|
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
|
||||||
|
if setting.CreemTestMode {
|
||||||
|
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
|
||||||
|
} else if signature == "" {
|
||||||
|
log.Printf("Creem Webhook缺少签名头")
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
|
||||||
|
log.Printf("Creem Webhook签名验证失败")
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem Webhook签名验证成功")
|
||||||
|
|
||||||
|
// 重新设置body供后续的ShouldBindJSON使用
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
|
||||||
|
// 解析新格式的webhook数据
|
||||||
|
var webhookEvent CreemWebhookEvent
|
||||||
|
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
|
||||||
|
log.Printf("解析Creem Webhook参数失败: %v", err)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
|
||||||
|
|
||||||
|
// 根据事件类型处理不同的webhook
|
||||||
|
switch webhookEvent.EventType {
|
||||||
|
case "checkout.completed":
|
||||||
|
handleCheckoutCompleted(c, &webhookEvent)
|
||||||
|
default:
|
||||||
|
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理支付完成事件
|
||||||
|
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||||
|
// 验证订单状态
|
||||||
|
if event.Object.Order.Status != "paid" {
|
||||||
|
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取引用ID(这是我们创建订单时传递的request_id)
|
||||||
|
referenceId := event.Object.RequestId
|
||||||
|
if referenceId == "" {
|
||||||
|
log.Println("Creem Webhook缺少request_id字段")
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证订单类型,目前只处理一次性付款
|
||||||
|
if event.Object.Order.Type != "onetime" {
|
||||||
|
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录详细的支付信息
|
||||||
|
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
|
||||||
|
referenceId,
|
||||||
|
event.Object.Order.Id,
|
||||||
|
event.Object.Order.AmountPaid,
|
||||||
|
event.Object.Order.Currency,
|
||||||
|
event.Object.Product.Name)
|
||||||
|
|
||||||
|
// 查询本地订单确认存在
|
||||||
|
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||||
|
if topUp == nil {
|
||||||
|
log.Printf("Creem充值订单不存在: %s", referenceId)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if topUp.Status != common.TopUpStatusPending {
|
||||||
|
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
|
||||||
|
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理充值,传入客户邮箱和姓名信息
|
||||||
|
customerEmail := event.Object.Customer.Email
|
||||||
|
customerName := event.Object.Customer.Name
|
||||||
|
|
||||||
|
// 防护性检查,确保邮箱和姓名不为空字符串
|
||||||
|
if customerEmail == "" {
|
||||||
|
log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
|
||||||
|
}
|
||||||
|
if customerName == "" {
|
||||||
|
log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := model.RechargeCreem(referenceId, customerEmail, customerName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
|
||||||
|
referenceId, topUp.Amount, topUp.Money)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemCheckoutRequest struct {
|
||||||
|
ProductId string `json:"product_id"`
|
||||||
|
RequestId string `json:"request_id"`
|
||||||
|
Customer struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"customer"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreemCheckoutResponse struct {
|
||||||
|
CheckoutUrl string `json:"checkout_url"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
|
||||||
|
if setting.CreemApiKey == "" {
|
||||||
|
return "", fmt.Errorf("未配置Creem API密钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据测试模式选择 API 端点
|
||||||
|
apiUrl := "https://api.creem.io/v1/checkouts"
|
||||||
|
if setting.CreemTestMode {
|
||||||
|
apiUrl = "https://test-api.creem.io/v1/checkouts"
|
||||||
|
log.Printf("使用Creem测试环境: %s", apiUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建请求数据,确保包含用户邮箱
|
||||||
|
requestData := CreemCheckoutRequest{
|
||||||
|
ProductId: product.ProductId,
|
||||||
|
RequestId: referenceId, // 这个作为订单ID传递给Creem
|
||||||
|
Customer: struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}{
|
||||||
|
Email: email, // 用户邮箱会在支付页面预填充
|
||||||
|
},
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"username": username,
|
||||||
|
"reference_id": referenceId,
|
||||||
|
"product_name": product.Name,
|
||||||
|
"quota": fmt.Sprintf("%d", product.Quota),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化请求数据
|
||||||
|
jsonData, err := json.Marshal(requestData)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("序列化请求数据失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 HTTP 请求
|
||||||
|
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置请求头
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-api-key", setting.CreemApiKey)
|
||||||
|
|
||||||
|
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
|
||||||
|
apiUrl, product.ProductId, email, referenceId)
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 读取响应
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
// 检查响应状态
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// 解析响应
|
||||||
|
var checkoutResp CreemCheckoutResponse
|
||||||
|
err = json.Unmarshal(body, &checkoutResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析响应失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkoutResp.CheckoutUrl == "" {
|
||||||
|
return "", fmt.Errorf("Creem API resp no checkout url ")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
|
||||||
|
return checkoutResp.CheckoutUrl, nil
|
||||||
|
}
|
||||||
@@ -84,6 +84,10 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["StripePriceId"] = setting.StripePriceId
|
common.OptionMap["StripePriceId"] = setting.StripePriceId
|
||||||
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
|
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
|
||||||
common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
|
common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
|
||||||
|
common.OptionMap["CreemApiKey"] = setting.CreemApiKey
|
||||||
|
common.OptionMap["CreemProducts"] = setting.CreemProducts
|
||||||
|
common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode)
|
||||||
|
common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret
|
||||||
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
||||||
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
||||||
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
||||||
@@ -342,6 +346,14 @@ func updateOptionMap(key string, value string) (err error) {
|
|||||||
setting.StripeMinTopUp, _ = strconv.Atoi(value)
|
setting.StripeMinTopUp, _ = strconv.Atoi(value)
|
||||||
case "StripePromotionCodesEnabled":
|
case "StripePromotionCodesEnabled":
|
||||||
setting.StripePromotionCodesEnabled = value == "true"
|
setting.StripePromotionCodesEnabled = value == "true"
|
||||||
|
case "CreemApiKey":
|
||||||
|
setting.CreemApiKey = value
|
||||||
|
case "CreemProducts":
|
||||||
|
setting.CreemProducts = value
|
||||||
|
case "CreemTestMode":
|
||||||
|
setting.CreemTestMode = value == "true"
|
||||||
|
case "CreemWebhookSecret":
|
||||||
|
setting.CreemWebhookSecret = value
|
||||||
case "TopupGroupRatio":
|
case "TopupGroupRatio":
|
||||||
err = common.UpdateTopupGroupRatioByJSONString(value)
|
err = common.UpdateTopupGroupRatioByJSONString(value)
|
||||||
case "GitHubClientId":
|
case "GitHubClientId":
|
||||||
|
|||||||
@@ -305,3 +305,72 @@ func ManualCompleteTopUp(tradeNo string) error {
|
|||||||
RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
|
RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
|
||||||
|
if referenceId == "" {
|
||||||
|
return errors.New("未提供支付单号")
|
||||||
|
}
|
||||||
|
|
||||||
|
var quota int64
|
||||||
|
topUp := &TopUp{}
|
||||||
|
|
||||||
|
refCol := "`trade_no`"
|
||||||
|
if common.UsingPostgreSQL {
|
||||||
|
refCol = `"trade_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("充值订单不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if topUp.Status != common.TopUpStatusPending {
|
||||||
|
return errors.New("充值订单状态错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp.CompleteTime = common.GetTimestamp()
|
||||||
|
topUp.Status = common.TopUpStatusSuccess
|
||||||
|
err = tx.Save(topUp).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creem 直接使用 Amount 作为充值额度(整数)
|
||||||
|
quota = topUp.Amount
|
||||||
|
|
||||||
|
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
|
||||||
|
updateFields := map[string]interface{}{
|
||||||
|
"quota": gorm.Expr("quota + ?", quota),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
|
||||||
|
if customerEmail != "" {
|
||||||
|
// 先检查用户当前邮箱是否为空
|
||||||
|
var user User
|
||||||
|
err = tx.Where("id = ?", topUp.UserId).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果用户邮箱为空,则更新为支付时使用的邮箱
|
||||||
|
if user.Email == "" {
|
||||||
|
updateFields["email"] = customerEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("充值失败," + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
|
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
|
||||||
|
|
||||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||||
|
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
|
||||||
|
|
||||||
// Universal secure verification routes
|
// Universal secure verification routes
|
||||||
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
|
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
|
||||||
@@ -81,6 +82,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
selfRoute.POST("/amount", controller.RequestAmount)
|
selfRoute.POST("/amount", controller.RequestAmount)
|
||||||
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
|
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
|
||||||
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
|
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
|
||||||
|
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
|
||||||
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
|
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
|
||||||
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
var CreemApiKey = ""
|
||||||
|
var CreemProducts = "[]"
|
||||||
|
var CreemTestMode = false
|
||||||
|
var CreemWebhookSecret = ""
|
||||||
@@ -22,6 +22,7 @@ import { Card, Spin } from '@douyinfe/semi-ui';
|
|||||||
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
|
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
|
||||||
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
|
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
|
||||||
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
|
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
|
||||||
|
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
|
||||||
import { API, showError, toBoolean } from '../../helpers';
|
import { API, showError, toBoolean } from '../../helpers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -142,6 +143,9 @@ const PaymentSetting = () => {
|
|||||||
<Card style={{ marginTop: '10px' }}>
|
<Card style={{ marginTop: '10px' }}>
|
||||||
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
|
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card style={{ marginTop: '10px' }}>
|
||||||
|
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
|
||||||
|
</Card>
|
||||||
</Spin>
|
</Spin>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ const RechargeCard = ({
|
|||||||
t,
|
t,
|
||||||
enableOnlineTopUp,
|
enableOnlineTopUp,
|
||||||
enableStripeTopUp,
|
enableStripeTopUp,
|
||||||
|
enableCreemTopUp,
|
||||||
|
creemProducts,
|
||||||
|
creemPreTopUp,
|
||||||
presetAmounts,
|
presetAmounts,
|
||||||
selectedPreset,
|
selectedPreset,
|
||||||
selectPresetAmount,
|
selectPresetAmount,
|
||||||
@@ -84,6 +87,7 @@ const RechargeCard = ({
|
|||||||
const onlineFormApiRef = useRef(null);
|
const onlineFormApiRef = useRef(null);
|
||||||
const redeemFormApiRef = useRef(null);
|
const redeemFormApiRef = useRef(null);
|
||||||
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
||||||
|
console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
{/* 卡片头部 */}
|
{/* 卡片头部 */}
|
||||||
@@ -216,7 +220,7 @@ const RechargeCard = ({
|
|||||||
<div className='py-8 flex justify-center'>
|
<div className='py-8 flex justify-center'>
|
||||||
<Spin size='large' />
|
<Spin size='large' />
|
||||||
</div>
|
</div>
|
||||||
) : enableOnlineTopUp || enableStripeTopUp ? (
|
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
|
||||||
<Form
|
<Form
|
||||||
getFormApi={(api) => (onlineFormApiRef.current = api)}
|
getFormApi={(api) => (onlineFormApiRef.current = api)}
|
||||||
initValues={{ topUpCount: topUpCount }}
|
initValues={{ topUpCount: topUpCount }}
|
||||||
@@ -480,6 +484,32 @@ const RechargeCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</Form.Slot>
|
</Form.Slot>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Creem 充值区域 */}
|
||||||
|
{enableCreemTopUp && creemProducts.length > 0 && (
|
||||||
|
<Form.Slot label={t('Creem 充值')}>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3'>
|
||||||
|
{creemProducts.map((product, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
onClick={() => creemPreTopUp(product)}
|
||||||
|
className='cursor-pointer !rounded-2xl transition-all hover:shadow-md border-gray-200 hover:border-gray-300'
|
||||||
|
bodyStyle={{ textAlign: 'center', padding: '16px' }}
|
||||||
|
>
|
||||||
|
<div className='font-medium text-lg mb-2'>
|
||||||
|
{product.name}
|
||||||
|
</div>
|
||||||
|
<div className='text-sm text-gray-600 mb-2'>
|
||||||
|
{t('充值额度')}: {product.quota}
|
||||||
|
</div>
|
||||||
|
<div className='text-lg font-semibold text-blue-600'>
|
||||||
|
{product.currency === 'EUR' ? '€' : '$'}{product.price}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Form.Slot>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ const TopUp = () => {
|
|||||||
);
|
);
|
||||||
const [statusLoading, setStatusLoading] = useState(true);
|
const [statusLoading, setStatusLoading] = useState(true);
|
||||||
|
|
||||||
|
// Creem 相关状态
|
||||||
|
const [creemProducts, setCreemProducts] = useState([]);
|
||||||
|
const [enableCreemTopUp, setEnableCreemTopUp] = useState(false);
|
||||||
|
const [creemOpen, setCreemOpen] = useState(false);
|
||||||
|
const [selectedCreemProduct, setSelectedCreemProduct] = useState(null);
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [payWay, setPayWay] = useState('');
|
const [payWay, setPayWay] = useState('');
|
||||||
@@ -248,6 +254,55 @@ const TopUp = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const creemPreTopUp = async (product) => {
|
||||||
|
if (!enableCreemTopUp) {
|
||||||
|
showError(t('管理员未开启 Creem 充值!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCreemProduct(product);
|
||||||
|
setCreemOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onlineCreemTopUp = async () => {
|
||||||
|
if (!selectedCreemProduct) {
|
||||||
|
showError(t('请选择产品'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Validate product has required fields
|
||||||
|
if (!selectedCreemProduct.productId) {
|
||||||
|
showError(t('产品配置错误,请联系管理员'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/creem/pay', {
|
||||||
|
product_id: selectedCreemProduct.productId,
|
||||||
|
payment_method: 'creem',
|
||||||
|
});
|
||||||
|
if (res !== undefined) {
|
||||||
|
const { message, data } = res.data;
|
||||||
|
if (message === 'success') {
|
||||||
|
processCreemCallback(data);
|
||||||
|
} else {
|
||||||
|
showError(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(res);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
showError(t('支付请求失败'));
|
||||||
|
} finally {
|
||||||
|
setCreemOpen(false);
|
||||||
|
setConfirmLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCreemCallback = (data) => {
|
||||||
|
// 与 Stripe 保持一致的实现方式
|
||||||
|
window.open(data.checkout_url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
const getUserQuota = async () => {
|
const getUserQuota = async () => {
|
||||||
let res = await API.get(`/api/user/self`);
|
let res = await API.get(`/api/user/self`);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
@@ -322,6 +377,7 @@ const TopUp = () => {
|
|||||||
setPayMethods(payMethods);
|
setPayMethods(payMethods);
|
||||||
const enableStripeTopUp = data.enable_stripe_topup || false;
|
const enableStripeTopUp = data.enable_stripe_topup || false;
|
||||||
const enableOnlineTopUp = data.enable_online_topup || false;
|
const enableOnlineTopUp = data.enable_online_topup || false;
|
||||||
|
const enableCreemTopUp = data.enable_creem_topup || false;
|
||||||
const minTopUpValue = enableOnlineTopUp
|
const minTopUpValue = enableOnlineTopUp
|
||||||
? data.min_topup
|
? data.min_topup
|
||||||
: enableStripeTopUp
|
: enableStripeTopUp
|
||||||
@@ -329,9 +385,20 @@ const TopUp = () => {
|
|||||||
: 1;
|
: 1;
|
||||||
setEnableOnlineTopUp(enableOnlineTopUp);
|
setEnableOnlineTopUp(enableOnlineTopUp);
|
||||||
setEnableStripeTopUp(enableStripeTopUp);
|
setEnableStripeTopUp(enableStripeTopUp);
|
||||||
|
setEnableCreemTopUp(enableCreemTopUp);
|
||||||
setMinTopUp(minTopUpValue);
|
setMinTopUp(minTopUpValue);
|
||||||
setTopUpCount(minTopUpValue);
|
setTopUpCount(minTopUpValue);
|
||||||
|
|
||||||
|
// 设置 Creem 产品
|
||||||
|
try {
|
||||||
|
console.log(' data is ?', data);
|
||||||
|
console.log(' creem products is ?', data.creem_products);
|
||||||
|
const products = JSON.parse(data.creem_products || '[]');
|
||||||
|
setCreemProducts(products);
|
||||||
|
} catch (e) {
|
||||||
|
setCreemProducts([]);
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
|
// 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
|
||||||
if (topupInfo.amount_options.length === 0) {
|
if (topupInfo.amount_options.length === 0) {
|
||||||
setPresetAmounts(generatePresetAmounts(minTopUpValue));
|
setPresetAmounts(generatePresetAmounts(minTopUpValue));
|
||||||
@@ -500,6 +567,11 @@ const TopUp = () => {
|
|||||||
setOpenHistory(false);
|
setOpenHistory(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreemCancel = () => {
|
||||||
|
setCreemOpen(false);
|
||||||
|
setSelectedCreemProduct(null);
|
||||||
|
};
|
||||||
|
|
||||||
// 选择预设充值额度
|
// 选择预设充值额度
|
||||||
const selectPresetAmount = (preset) => {
|
const selectPresetAmount = (preset) => {
|
||||||
setTopUpCount(preset.value);
|
setTopUpCount(preset.value);
|
||||||
@@ -563,6 +635,33 @@ const TopUp = () => {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Creem 充值确认模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={t('确定要充值 $')}
|
||||||
|
visible={creemOpen}
|
||||||
|
onOk={onlineCreemTopUp}
|
||||||
|
onCancel={handleCreemCancel}
|
||||||
|
maskClosable={false}
|
||||||
|
size='small'
|
||||||
|
centered
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
>
|
||||||
|
{selectedCreemProduct && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{t('产品名称')}:{selectedCreemProduct.name}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t('充值额度')}:{selectedCreemProduct.quota}
|
||||||
|
</p>
|
||||||
|
<p>{t('是否确认充值?')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 用户信息头部 */}
|
{/* 用户信息头部 */}
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||||
@@ -572,6 +671,9 @@ const TopUp = () => {
|
|||||||
t={t}
|
t={t}
|
||||||
enableOnlineTopUp={enableOnlineTopUp}
|
enableOnlineTopUp={enableOnlineTopUp}
|
||||||
enableStripeTopUp={enableStripeTopUp}
|
enableStripeTopUp={enableStripeTopUp}
|
||||||
|
enableCreemTopUp={enableCreemTopUp}
|
||||||
|
creemProducts={creemProducts}
|
||||||
|
creemPreTopUp={creemPreTopUp}
|
||||||
presetAmounts={presetAmounts}
|
presetAmounts={presetAmounts}
|
||||||
selectedPreset={selectedPreset}
|
selectedPreset={selectedPreset}
|
||||||
selectPresetAmount={selectPresetAmount}
|
selectPresetAmount={selectPresetAmount}
|
||||||
|
|||||||
@@ -2071,6 +2071,35 @@
|
|||||||
"默认区域,如: us-central1": "Default region, e.g.: us-central1",
|
"默认区域,如: us-central1": "Default region, e.g.: us-central1",
|
||||||
"默认折叠侧边栏": "Default collapse sidebar",
|
"默认折叠侧边栏": "Default collapse sidebar",
|
||||||
"默认测试模型": "Default Test Model",
|
"默认测试模型": "Default Test Model",
|
||||||
"默认补全倍率": "Default completion ratio"
|
"默认补全倍率": "Default completion ratio",
|
||||||
|
"选择充值套餐": "Choose a top-up package",
|
||||||
|
"Creem 设置": "Creem Setting",
|
||||||
|
"Creem 充值": "Creem Recharge",
|
||||||
|
"Creem 介绍": "Creem is the payment partner you always deserved, we strive for simplicity and straightforwardness on our APIs.",
|
||||||
|
"Creem Setting Tips": "Creem only supports preset fixed-amount products. These products and their prices need to be created and configured in advance on the Creem website, so custom dynamic amount top-ups are not supported. Configure the product name and price on Creem, obtain the Product Id, and then fill it in for the product below. Set the top-up amount and display price for this product in the new API.",
|
||||||
|
"Webhook 密钥": "Webhook Secret",
|
||||||
|
"测试模式": "Test Mode",
|
||||||
|
"Creem API 密钥,敏感信息不显示": "Creem API key, sensitive information not displayed",
|
||||||
|
"用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示": "The key used to validate webhook requests for the callback new-api, sensitive information is not displayed.",
|
||||||
|
"启用后将使用 Creem Test Mode": "",
|
||||||
|
"展示价格": "Display Pricing",
|
||||||
|
"Recharge Quota": "Recharge Quota",
|
||||||
|
"产品配置": "Product Configuration",
|
||||||
|
"产品名称": "Product Name",
|
||||||
|
"产品ID": "Product ID",
|
||||||
|
"暂无产品配置": "No product configuration",
|
||||||
|
"更新 Creem 设置": "Update Creem Settings",
|
||||||
|
"编辑产品": "Edit Product",
|
||||||
|
"添加产品": "Add Product",
|
||||||
|
"例如:基础套餐": "e.g.: Basic Package",
|
||||||
|
"例如:prod_6I8rBerHpPxyoiU9WK4kot": "e.g.: prod_6I8rBerHpPxyoiU9WK4kot",
|
||||||
|
"货币": "Currency",
|
||||||
|
"欧元": "EUR",
|
||||||
|
"USD (美元)": "USD (US Dollar)",
|
||||||
|
"EUR (欧元)": "EUR (Euro)",
|
||||||
|
"例如:4.99": "e.g.: 4.99",
|
||||||
|
"例如:100000": "e.g.: 100000",
|
||||||
|
"请填写完整的产品信息": "Please fill in complete product information",
|
||||||
|
"产品ID已存在": "Product ID already exists"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2062,6 +2062,8 @@
|
|||||||
"默认区域,如: us-central1": "默认区域,如: us-central1",
|
"默认区域,如: us-central1": "默认区域,如: us-central1",
|
||||||
"默认折叠侧边栏": "默认折叠侧边栏",
|
"默认折叠侧边栏": "默认折叠侧边栏",
|
||||||
"默认测试模型": "默认测试模型",
|
"默认测试模型": "默认测试模型",
|
||||||
"默认补全倍率": "默认补全倍率"
|
"默认补全倍率": "默认补全倍率",
|
||||||
|
"Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。",
|
||||||
|
"Creem Setting Tips": "Creem 只支持预设的固定金额产品,这产品以及价格需要提前在Creem网站内创建配置,所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格,获取Product Id 后填到下面的产品,在new-api为该产品设置充值额度,以及展示价格。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Typography,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Select,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
const { Text } = Typography;
|
||||||
|
import {
|
||||||
|
API,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
} from '../../../helpers';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function SettingsPaymentGatewayCreem(props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
CreemApiKey: '',
|
||||||
|
CreemWebhookSecret: '',
|
||||||
|
CreemProducts: '[]',
|
||||||
|
CreemTestMode: false,
|
||||||
|
});
|
||||||
|
const [originInputs, setOriginInputs] = useState({});
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [showProductModal, setShowProductModal] = useState(false);
|
||||||
|
const [editingProduct, setEditingProduct] = useState(null);
|
||||||
|
const [productForm, setProductForm] = useState({
|
||||||
|
name: '',
|
||||||
|
productId: '',
|
||||||
|
price: 0,
|
||||||
|
quota: 0,
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
const formApiRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.options && formApiRef.current) {
|
||||||
|
const currentInputs = {
|
||||||
|
CreemApiKey: props.options.CreemApiKey || '',
|
||||||
|
CreemWebhookSecret: props.options.CreemWebhookSecret || '',
|
||||||
|
CreemProducts: props.options.CreemProducts || '[]',
|
||||||
|
CreemTestMode: props.options.CreemTestMode === 'true',
|
||||||
|
};
|
||||||
|
setInputs(currentInputs);
|
||||||
|
setOriginInputs({ ...currentInputs });
|
||||||
|
formApiRef.current.setValues(currentInputs);
|
||||||
|
|
||||||
|
// Parse products
|
||||||
|
try {
|
||||||
|
const parsedProducts = JSON.parse(currentInputs.CreemProducts);
|
||||||
|
setProducts(parsedProducts);
|
||||||
|
} catch (e) {
|
||||||
|
setProducts([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [props.options]);
|
||||||
|
|
||||||
|
const handleFormChange = (values) => {
|
||||||
|
setInputs(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCreemSetting = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const options = [];
|
||||||
|
|
||||||
|
if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
|
||||||
|
options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
|
||||||
|
options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save test mode setting
|
||||||
|
options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
|
||||||
|
|
||||||
|
// Save products as JSON string
|
||||||
|
options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const requestQueue = options.map(opt =>
|
||||||
|
API.put('/api/option/', {
|
||||||
|
key: opt.key,
|
||||||
|
value: opt.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(requestQueue);
|
||||||
|
|
||||||
|
// 检查所有请求是否成功
|
||||||
|
const errorResults = results.filter(res => !res.data.success);
|
||||||
|
if (errorResults.length > 0) {
|
||||||
|
errorResults.forEach(res => {
|
||||||
|
showError(res.data.message);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showSuccess(t('更新成功'));
|
||||||
|
// 更新本地存储的原始值
|
||||||
|
setOriginInputs({ ...inputs });
|
||||||
|
props.refresh?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('更新失败'));
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProductModal = (product = null) => {
|
||||||
|
if (product) {
|
||||||
|
setEditingProduct(product);
|
||||||
|
setProductForm({ ...product });
|
||||||
|
} else {
|
||||||
|
setEditingProduct(null);
|
||||||
|
setProductForm({
|
||||||
|
name: '',
|
||||||
|
productId: '',
|
||||||
|
price: 0,
|
||||||
|
quota: 0,
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowProductModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeProductModal = () => {
|
||||||
|
setShowProductModal(false);
|
||||||
|
setEditingProduct(null);
|
||||||
|
setProductForm({
|
||||||
|
name: '',
|
||||||
|
productId: '',
|
||||||
|
price: 0,
|
||||||
|
quota: 0,
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveProduct = () => {
|
||||||
|
if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) {
|
||||||
|
showError(t('请填写完整的产品信息'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newProducts = [...products];
|
||||||
|
if (editingProduct) {
|
||||||
|
// 编辑现有产品
|
||||||
|
const index = newProducts.findIndex(p => p.productId === editingProduct.productId);
|
||||||
|
if (index !== -1) {
|
||||||
|
newProducts[index] = { ...productForm };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 添加新产品
|
||||||
|
if (newProducts.find(p => p.productId === productForm.productId)) {
|
||||||
|
showError(t('产品ID已存在'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newProducts.push({ ...productForm });
|
||||||
|
}
|
||||||
|
|
||||||
|
setProducts(newProducts);
|
||||||
|
closeProductModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProduct = (productId) => {
|
||||||
|
const newProducts = products.filter(p => p.productId !== productId);
|
||||||
|
setProducts(newProducts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('产品名称'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('产品ID'),
|
||||||
|
dataIndex: 'productId',
|
||||||
|
key: 'productId',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('展示价格'),
|
||||||
|
dataIndex: 'price',
|
||||||
|
key: 'price',
|
||||||
|
render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('充值额度'),
|
||||||
|
dataIndex: 'quota',
|
||||||
|
key: 'quota',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('操作'),
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
size='small'
|
||||||
|
onClick={() => openProductModal(record)}
|
||||||
|
>
|
||||||
|
{t('编辑')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
theme='borderless'
|
||||||
|
size='small'
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
onClick={() => deleteProduct(record.productId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Form
|
||||||
|
initValues={inputs}
|
||||||
|
onValueChange={handleFormChange}
|
||||||
|
getFormApi={(api) => (formApiRef.current = api)}
|
||||||
|
>
|
||||||
|
<Form.Section text={t('Creem 设置')}>
|
||||||
|
<Text>
|
||||||
|
{t('Creem 介绍')}
|
||||||
|
<a
|
||||||
|
href='https://creem.io'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>Creem Official Site</a>
|
||||||
|
<br />
|
||||||
|
</Text>
|
||||||
|
<Banner
|
||||||
|
type='info'
|
||||||
|
description={t('Creem Setting Tips')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||||
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
<Form.Input
|
||||||
|
field='CreemApiKey'
|
||||||
|
label={t('API 密钥')}
|
||||||
|
placeholder={t('Creem API 密钥,敏感信息不显示')}
|
||||||
|
type='password'
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
<Form.Input
|
||||||
|
field='CreemWebhookSecret'
|
||||||
|
label={t('Webhook 密钥')}
|
||||||
|
placeholder={t('用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示')}
|
||||||
|
type='password'
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
<Form.Switch
|
||||||
|
field='CreemTestMode'
|
||||||
|
label={t('测试模式')}
|
||||||
|
extraText={t('启用后将使用 Creem Test Mode')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<div className='flex justify-between items-center mb-4'>
|
||||||
|
<Text strong>{t('产品配置')}</Text>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
onClick={() => openProductModal()}
|
||||||
|
>
|
||||||
|
{t('添加产品')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={products}
|
||||||
|
pagination={false}
|
||||||
|
empty={
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<Text type='tertiary'>{t('暂无产品配置')}</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
|
||||||
|
{t('更新 Creem 设置')}
|
||||||
|
</Button>
|
||||||
|
</Form.Section>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* 产品配置模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={editingProduct ? t('编辑产品') : t('添加产品')}
|
||||||
|
visible={showProductModal}
|
||||||
|
onOk={saveProduct}
|
||||||
|
onCancel={closeProductModal}
|
||||||
|
maskClosable={false}
|
||||||
|
size='small'
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<Text strong className='block mb-2'>
|
||||||
|
{t('产品名称')}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={productForm.name}
|
||||||
|
onChange={(value) => setProductForm({ ...productForm, name: value })}
|
||||||
|
placeholder={t('例如:基础套餐')}
|
||||||
|
size='large'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text strong className='block mb-2'>
|
||||||
|
{t('产品ID')}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={productForm.productId}
|
||||||
|
onChange={(value) => setProductForm({ ...productForm, productId: value })}
|
||||||
|
placeholder={t('例如:prod_6I8rBerHpPxyoiU9WK4kot')}
|
||||||
|
size='large'
|
||||||
|
disabled={!!editingProduct}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text strong className='block mb-2'>
|
||||||
|
{t('货币')}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={productForm.currency}
|
||||||
|
onChange={(value) => setProductForm({ ...productForm, currency: value })}
|
||||||
|
size='large'
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
<Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
|
||||||
|
<Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text strong className='block mb-2'>
|
||||||
|
{t('价格')} ({productForm.currency === 'EUR' ? t('欧元') : t('美元')})
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
value={productForm.price}
|
||||||
|
onChange={(value) => setProductForm({ ...productForm, price: value })}
|
||||||
|
placeholder={t('例如:4.99')}
|
||||||
|
min={0.01}
|
||||||
|
precision={2}
|
||||||
|
size='large'
|
||||||
|
className='w-full'
|
||||||
|
defaultValue={4.49}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text strong className='block mb-2'>
|
||||||
|
{t('充值额度')}
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
value={productForm.quota}
|
||||||
|
onChange={(value) => setProductForm({ ...productForm, quota: value })}
|
||||||
|
placeholder={t('例如:100000')}
|
||||||
|
min={1}
|
||||||
|
precision={0}
|
||||||
|
size='large'
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user