Merge branch 'sub' into feature/subscription

This commit is contained in:
t0ng7u
2026-02-02 23:45:05 +08:00
18 changed files with 150 additions and 122 deletions

View File

@@ -207,21 +207,23 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
err := model.DB.Transaction(func(tx *gorm.DB) error {
// update plan (allow zero values updates with map)
updateMap := map[string]interface{}{
"title": req.Plan.Title,
"subtitle": req.Plan.Subtitle,
"price_amount": req.Plan.PriceAmount,
"currency": req.Plan.Currency,
"duration_unit": req.Plan.DurationUnit,
"duration_value": req.Plan.DurationValue,
"custom_seconds": req.Plan.CustomSeconds,
"enabled": req.Plan.Enabled,
"sort_order": req.Plan.SortOrder,
"stripe_price_id": req.Plan.StripePriceId,
"creem_product_id": req.Plan.CreemProductId,
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
"total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup,
"updated_at": common.GetTimestamp(),
"title": req.Plan.Title,
"subtitle": req.Plan.Subtitle,
"price_amount": req.Plan.PriceAmount,
"currency": req.Plan.Currency,
"duration_unit": req.Plan.DurationUnit,
"duration_value": req.Plan.DurationValue,
"custom_seconds": req.Plan.CustomSeconds,
"enabled": req.Plan.Enabled,
"sort_order": req.Plan.SortOrder,
"stripe_price_id": req.Plan.StripePriceId,
"creem_product_id": req.Plan.CreemProductId,
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
"total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod,
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(),
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
return err

View File

@@ -54,7 +54,15 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
}
userId := c.GetInt("id")
user, _ := model.GetUserById(userId, false)
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
return
}
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)

View File

@@ -61,8 +61,16 @@ func SubscriptionRequestEpay(c *gin.Context) {
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/return")
notifyUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/notify")
returnUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/return")
if err != nil {
common.ApiErrorMsg(c, "回调地址配置错误")
return
}
notifyUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/notify")
if err != nil {
common.ApiErrorMsg(c, "回调地址配置错误")
return
}
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo)

View File

@@ -51,7 +51,15 @@ func SubscriptionRequestStripePay(c *gin.Context) {
}
userId := c.GetInt("id")
user, _ := model.GetUserById(userId, false)
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
if user == nil {
common.ApiErrorMsg(c, "用户不存在")
return
}
if plan.MaxPurchasePerUser > 0 {
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
@@ -110,7 +118,7 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri
Quantity: stripe.Int64(1),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
}
if "" == customerId {

View File

@@ -9,7 +9,7 @@ func GetDBTimestamp() int64 {
var err error
switch {
case common.UsingPostgreSQL:
err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())").Scan(&ts).Error
err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error
case common.UsingSQLite:
err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error
default:

View File

@@ -508,7 +508,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
if relayInfo.SubscriptionId == 0 {
return errors.New("subscription id is missing")
}
delta := int64(quota) - relayInfo.SubscriptionPreConsumed
delta := int64(quota)
if delta != 0 {
if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil {
return err

View File

@@ -202,9 +202,10 @@ const renderResetPeriod = (text, record, t) => {
);
};
const renderPaymentConfig = (text, record, t) => {
const renderPaymentConfig = (text, record, t, enableEpay) => {
const hasStripe = !!record?.plan?.stripe_price_id;
const hasCreem = !!record?.plan?.creem_product_id;
const hasEpay = !!enableEpay;
return (
<Space spacing={4}>
@@ -218,9 +219,11 @@ const renderPaymentConfig = (text, record, t) => {
Creem
</Tag>
)}
<Tag color='light-green' shape='circle'>
{t('易支付')}
</Tag>
{hasEpay && (
<Tag color='light-green' shape='circle'>
{t('易支付')}
</Tag>
)}
</Space>
);
};
@@ -274,7 +277,12 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
);
};
export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
export const getSubscriptionsColumns = ({
t,
openEdit,
setPlanEnabled,
enableEpay,
}) => {
return [
{
title: 'ID',
@@ -324,7 +332,8 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
{
title: t('支付渠道'),
width: 180,
render: (text, record) => renderPaymentConfig(text, record, t),
render: (text, record) =>
renderPaymentConfig(text, record, t, enableEpay),
},
{
title: t('总额度'),

View File

@@ -27,16 +27,24 @@ import {
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
const SubscriptionsTable = (subscriptionsData) => {
const { plans, loading, compactMode, openEdit, setPlanEnabled, t } =
subscriptionsData;
const {
plans,
loading,
compactMode,
openEdit,
setPlanEnabled,
t,
enableEpay,
} = subscriptionsData;
const columns = useMemo(() => {
return getSubscriptionsColumns({
t,
openEdit,
setPlanEnabled,
enableEpay,
});
}, [t, openEdit, setPlanEnabled]);
}, [t, openEdit, setPlanEnabled, enableEpay]);
const tableColumns = useMemo(() => {
return compactMode

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import React, { useContext } from 'react';
import { Banner } from '@douyinfe/semi-ui';
import CardPro from '../../common/ui/CardPro';
import SubscriptionsTable from './SubscriptionsTable';
@@ -27,10 +27,13 @@ import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
import { StatusContext } from '../../../context/Status';
const SubscriptionsPage = () => {
const subscriptionsData = useSubscriptionsData();
const isMobile = useIsMobile();
const [statusState] = useContext(StatusContext);
const enableEpay = !!statusState?.status?.enable_online_topup;
const {
showEdit,
@@ -91,7 +94,7 @@ const SubscriptionsPage = () => {
})}
t={t}
>
<SubscriptionsTable {...subscriptionsData} />
<SubscriptionsTable {...subscriptionsData} enableEpay={enableEpay} />
</CardPro>
</>
);

View File

@@ -423,7 +423,7 @@ const AddEditSubscriptionModal = ({
field='custom_seconds'
label={t('自定义秒数')}
required
min={0}
min={1}
precision={0}
rules={[{ required: true, message: t('请输入秒数') }]}
style={{ width: '100%' }}

View File

@@ -35,45 +35,13 @@ import { API, showError, showSuccess, renderQuota } from '../../helpers';
import { getCurrencyConfig } from '../../helpers/render';
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
import {
formatSubscriptionDuration,
formatSubscriptionResetPeriod,
} from '../../helpers/subscriptionFormat';
const { Text } = Typography;
// 格式化有效期显示
function formatDuration(plan, t) {
const unit = plan?.duration_unit || 'month';
const value = plan?.duration_value || 1;
const unitLabels = {
year: t('年'),
month: t('个月'),
day: t('天'),
hour: t('小时'),
custom: t('自定义'),
};
if (unit === 'custom') {
const seconds = plan?.custom_seconds || 0;
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
return `${seconds} ${t('秒')}`;
}
return `${value} ${unitLabels[unit] || unit}`;
}
function formatResetPeriod(plan, t) {
const period = plan?.quota_reset_period || 'never';
if (period === 'never') return t('不重置');
if (period === 'daily') return t('每天');
if (period === 'weekly') return t('每周');
if (period === 'monthly') return t('每月');
if (period === 'custom') {
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
return `${seconds} ${t('秒')}`;
}
return t('不重置');
}
// 过滤易支付方式
function getEpayMethods(payMethods = []) {
return (payMethods || []).filter(
@@ -473,8 +441,9 @@ const SubscriptionPlansCard = ({
const totalAmount = Number(plan?.total_amount || 0);
const { symbol, rate } = getCurrencyConfig();
const price = Number(plan?.price_amount || 0);
const displayPrice = (price * rate).toFixed(
price % 1 === 0 ? 0 : 2,
const convertedPrice = price * rate;
const displayPrice = convertedPrice.toFixed(
Number.isInteger(convertedPrice) ? 0 : 2,
);
const isPopular = index === 0 && plans.length > 1;
const limit = Number(plan?.max_purchase_per_user || 0);
@@ -487,11 +456,13 @@ const SubscriptionPlansCard = ({
? `${t('升级分组')}: ${plan.upgrade_group}`
: null;
const resetLabel =
formatResetPeriod(plan, t) === t('不重置')
formatSubscriptionResetPeriod(plan, t) === t('不重置')
? null
: `${t('额度重置')}: ${formatResetPeriod(plan, t)}`;
: `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`;
const planBenefits = [
{ label: `${t('有效期')}: ${formatDuration(plan, t)}` },
{
label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`,
},
resetLabel ? { label: resetLabel } : null,
totalAmount > 0
? {

View File

@@ -356,6 +356,7 @@ const TopUp = () => {
};
const updateBillingPreference = async (pref) => {
const previousPref = billingPreference;
setBillingPreference(pref);
try {
const res = await API.put('/api/subscription/self/preference', {
@@ -363,11 +364,16 @@ const TopUp = () => {
});
if (res.data?.success) {
showSuccess(t('更新成功'));
const normalizedPref =
res.data?.data?.billing_preference || pref || previousPref;
setBillingPreference(normalizedPref);
} else {
showError(res.data?.message || t('更新失败'));
setBillingPreference(previousPref);
}
} catch (e) {
showError(t('请求失败'));
setBillingPreference(previousPref);
}
};

View File

@@ -33,45 +33,13 @@ import { SiStripe } from 'react-icons/si';
import { IconCreditCard } from '@douyinfe/semi-icons';
import { renderQuota } from '../../../helpers';
import { getCurrencyConfig } from '../../../helpers/render';
import {
formatSubscriptionDuration,
formatSubscriptionResetPeriod,
} from '../../../helpers/subscriptionFormat';
const { Text } = Typography;
// 格式化有效期显示
function formatDuration(plan, t) {
const unit = plan?.duration_unit || 'month';
const value = plan?.duration_value || 1;
const unitLabels = {
year: t('年'),
month: t('个月'),
day: t('天'),
hour: t('小时'),
custom: t('自定义'),
};
if (unit === 'custom') {
const seconds = plan?.custom_seconds || 0;
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
return `${seconds} ${t('秒')}`;
}
return `${value} ${unitLabels[unit] || unit}`;
}
function formatResetPeriod(plan, t) {
const period = plan?.quota_reset_period || 'never';
if (period === 'never') return t('不重置');
if (period === 'daily') return t('每天');
if (period === 'weekly') return t('每周');
if (period === 'monthly') return t('每月');
if (period === 'custom') {
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
return `${seconds} ${t('秒')}`;
}
return t('不重置');
}
const SubscriptionPurchaseModal = ({
t,
visible,
@@ -93,7 +61,10 @@ const SubscriptionPurchaseModal = ({
const totalAmount = Number(plan?.total_amount || 0);
const { symbol, rate } = getCurrencyConfig();
const price = plan ? Number(plan.price_amount || 0) : 0;
const displayPrice = (price * rate).toFixed(price % 1 === 0 ? 0 : 2);
const convertedPrice = price * rate;
const displayPrice = convertedPrice.toFixed(
Number.isInteger(convertedPrice) ? 0 : 2,
);
// 只有当管理员开启支付网关 AND 套餐配置了对应的支付ID时才显示
const hasStripe = enableStripeTopUp && !!plan?.stripe_price_id;
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
@@ -142,17 +113,17 @@ const SubscriptionPurchaseModal = ({
<div className='flex items-center'>
<CalendarClock size={14} className='mr-1 text-slate-500' />
<Text className='text-slate-900 dark:text-slate-100'>
{formatDuration(plan, t)}
{formatSubscriptionDuration(plan, t)}
</Text>
</div>
</div>
{formatResetPeriod(plan, t) !== t('不重置') && (
{formatSubscriptionResetPeriod(plan, t) !== t('不重置') && (
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('重置周期')}
</Text>
<Text className='text-slate-900 dark:text-slate-100'>
{formatResetPeriod(plan, t)}
{formatSubscriptionResetPeriod(plan, t)}
</Text>
</div>
)}

View File

@@ -0,0 +1,34 @@
export function formatSubscriptionDuration(plan, t) {
const unit = plan?.duration_unit || 'month';
const value = plan?.duration_value || 1;
const unitLabels = {
year: t('年'),
month: t('个月'),
day: t('天'),
hour: t('小时'),
custom: t('自定义'),
};
if (unit === 'custom') {
const seconds = plan?.custom_seconds || 0;
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
return `${seconds} ${t('秒')}`;
}
return `${value} ${unitLabels[unit] || unit}`;
}
export function formatSubscriptionResetPeriod(plan, t) {
const period = plan?.quota_reset_period || 'never';
if (period === 'never') return t('不重置');
if (period === 'daily') return t('每天');
if (period === 'weekly') return t('每周');
if (period === 'monthly') return t('每月');
if (period === 'custom') {
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
return `${seconds} ${t('秒')}`;
}
return t('不重置');
}

View File

@@ -2660,7 +2660,7 @@
"推荐": "おすすめ",
"已达到购买上限": "購入上限に達しました",
"已达上限": "上限に達しました",
"立即订阅": "今すぐ購読",
"立即订阅": "今すぐサブスクリプション",
"暂无可购买套餐": "購入可能なプランがありません",
"该套餐未配置 Stripe": "このプランには Stripe が設定されていません",
"已打开支付页面": "決済ページを開きました",

View File

@@ -2616,7 +2616,7 @@
"格式化 JSON": "Форматировать JSON",
"关闭提示": "Закрыть уведомление",
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Примечание: тесты на этой странице используют нестриминговые запросы. Если канал поддерживает только стриминговые ответы, тест может завершиться неудачей. Ориентируйтесь на реальное использование.",
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление endpoint'ов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».",
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление эндпоинтов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».",
"Stripe/Creem 需在第三方平台创建商品并填入 ID": "Товары Stripe/Creem нужно создать на сторонней платформе и указать их ID",
"暂无订阅套餐": "Нет тарифных планов",
"订阅管理": "Управление подписками",

View File

@@ -3228,9 +3228,9 @@
"仅用订阅": "Chỉ dùng đăng ký",
"仅用钱包": "Chỉ dùng ví",
"我的订阅": "Đăng ký của tôi",
"个生效中": "đang hiệu lực",
"无生效": "Không có hiệu lực",
"个已过期": "đã hết hạn",
"个生效中": "gói đăng ký đang hiệu lực",
"无生效": "Không có gói đăng ký hiệu lực",
"个已过期": "gói đăng ký đã hết hạn",
"订阅": "Đăng ký",
"过期于": "Hết hạn vào",
"购买套餐后即可享受模型权益": "Mua gói để nhận quyền lợi mô hình",

View File

@@ -18,12 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import SubscriptionsTable from '../../components/table/subscriptions';
import SubscriptionsPage from '../../components/table/subscriptions';
const Subscription = () => {
return (
<div className='mt-[60px] px-2'>
<SubscriptionsTable />
<SubscriptionsPage />
</div>
);
};