Commit 4ed5f1ab by Seefs Committed by GitHub

Merge pull request #1823 from littlewrite/feat_subscribe_sp1

新增 creem 支付
parents 2c74048f dca9bac4
...@@ -51,6 +51,8 @@ func GetTopUpInfo(c *gin.Context) { ...@@ -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,
......
...@@ -84,6 +84,10 @@ func InitOptionMap() { ...@@ -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) { ...@@ -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 { ...@@ -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) { ...@@ -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) { ...@@ -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)
......
package setting
var CreemApiKey = ""
var CreemProducts = "[]"
var CreemTestMode = false
var CreemWebhookSecret = ""
...@@ -22,6 +22,7 @@ import { Card, Spin } from '@douyinfe/semi-ui'; ...@@ -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 = () => { ...@@ -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 = ({ ...@@ -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 = ({ ...@@ -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 = ({ ...@@ -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 = ({ ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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 @@ ...@@ -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"
} }
} }
\ No newline at end of file
...@@ -2062,6 +2062,8 @@ ...@@ -2062,6 +2062,8 @@
"默认区域,如: us-central1": "默认区域,如: us-central1", "默认区域,如: us-central1": "默认区域,如: us-central1",
"默认折叠侧边栏": "默认折叠侧边栏", "默认折叠侧边栏": "默认折叠侧边栏",
"默认测试模型": "默认测试模型", "默认测试模型": "默认测试模型",
"默认补全倍率": "默认补全倍率" "默认补全倍率": "默认补全倍率",
"Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。",
"Creem Setting Tips": "Creem 只支持预设的固定金额产品,这产品以及价格需要提前在Creem网站内创建配置,所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格,获取Product Id 后填到下面的产品,在new-api为该产品设置充值额度,以及展示价格。"
} }
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment